From 8cdf0683a3c9f9aef86110c8f59e6130a8ee44ef Mon Sep 17 00:00:00 2001 From: Rome Thorstenson <36779795+Rome-1@users.noreply.github.com> Date: Mon, 29 Jun 2026 06:51:47 -0700 Subject: [PATCH] fix(core): collapse child agent initial input into a single user message (#589) --- strix/core/inputs.py | 43 ++++++++-------- tests/test_inputs.py | 114 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 23 deletions(-) create mode 100644 tests/test_inputs.py diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 0505ef3..2bcc077 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -136,30 +136,27 @@ def child_initial_input( task: str, parent_history: list[Any], ) -> list[dict[str, Any]]: - initial_input: list[dict[str, Any]] = [] + """Build the initial input for a child agent as a single user message. + + Collapsing the inherited-context block, the identity line, and the task into + one ``{"role": "user"}`` message keeps providers that require strictly + alternating roles (e.g. Perplexity, llama.cpp) from rejecting consecutive + user messages. + """ + parts: list[str] = [] if parent_history: rendered = json.dumps(parent_history, ensure_ascii=False, default=str) - initial_input.append( - { - "role": "user", - "content": ( - "== Inherited context from parent (background only) ==\n" - f"{rendered}\n" - "== End of inherited context ==\n" - "Use the above as background only; do not continue the " - "parent's work. Your task follows." - ), - }, + parts.append( + "== Inherited context from parent (background only) ==\n" + f"{rendered}\n" + "== End of inherited context ==\n" + "Use the above as background only; do not continue the " + "parent's work. Your task follows.", ) - initial_input.append( - { - "role": "user", - "content": ( - f"You are agent {name} ({child_id}); your parent is {parent_id}. " - "Maintain your own identity. Call agent_finish when your task " - "is complete." - ), - } + parts.append( + f"You are agent {name} ({child_id}); your parent is {parent_id}. " + "Maintain your own identity. Call agent_finish when your task " + "is complete.", ) - initial_input.append({"role": "user", "content": task}) - return initial_input + parts.append(task) + return [{"role": "user", "content": "\n\n".join(parts)}] diff --git a/tests/test_inputs.py b/tests/test_inputs.py new file mode 100644 index 0000000..c8fcf60 --- /dev/null +++ b/tests/test_inputs.py @@ -0,0 +1,114 @@ +"""Tests for pure input builders in strix.core.inputs.""" + +from __future__ import annotations + +from itertools import pairwise +from typing import Any + +import pytest + +from strix.core.inputs import build_root_task, child_initial_input + + +def _child_kwargs(parent_history: list[Any]) -> dict[str, Any]: + return { + "name": "scout", + "child_id": "agent-2", + "parent_id": "agent-1", + "task": "Audit the login flow.", + "parent_history": parent_history, + } + + +def test_child_initial_input_single_message_without_history() -> None: + result = child_initial_input(**_child_kwargs([])) + + assert len(result) == 1 + assert result[0]["role"] == "user" + content = result[0]["content"] + assert "agent scout (agent-2)" in content + assert "Audit the login flow." in content + assert "Inherited context" not in content + + +def test_child_initial_input_single_message_with_history() -> None: + history = [{"role": "assistant", "content": "previous work"}] + result = child_initial_input(**_child_kwargs(history)) + + assert len(result) == 1 + assert result[0]["role"] == "user" + content = result[0]["content"] + assert "Inherited context from parent" in content + assert "previous work" in content + assert "agent scout (agent-2)" in content + assert "Audit the login flow." in content + + +@pytest.mark.parametrize( + "parent_history", + [[], [{"role": "assistant", "content": "previous work"}]], +) +def test_child_initial_input_no_consecutive_same_role(parent_history: list[Any]) -> None: + result = child_initial_input(**_child_kwargs(parent_history)) + + roles = [msg["role"] for msg in result] + assert all(prev != nxt for prev, nxt in pairwise(roles)) + + +def test_build_root_task_empty_config() -> None: + assert build_root_task({}) == "" + + +def test_build_root_task_repository_target() -> None: + config = { + "targets": [ + { + "type": "repository", + "details": { + "target_repo": "https://example.com/repo.git", + "cloned_repo_path": "/workspace/repo", + "workspace_subdir": "repo", + }, + }, + ], + } + task = build_root_task(config) + + assert "Repositories:" in task + assert "/workspace/repo" in task + assert "https://example.com/repo.git" in task + + +def test_build_root_task_web_application_with_instructions() -> None: + config = { + "targets": [ + {"type": "web_application", "details": {"target_url": "https://app.example.com"}}, + ], + "user_instructions": "Focus on auth.", + } + task = build_root_task(config) + + assert "URLs:" in task + assert "https://app.example.com" in task + assert "Special instructions: Focus on auth." in task + + +def test_build_root_task_diff_scope() -> None: + config = { + "targets": [], + "diff_scope": { + "active": True, + "repos": [ + { + "workspace_subdir": "repo", + "analyzable_files_count": 3, + "deleted_files_count": 2, + }, + ], + }, + } + task = build_root_task(config) + + assert "Scope Constraints:" in task + assert "3 changed file(s)" in task + assert "2 deleted file(s)" in task