From f8ed49e32b54d022e0affdf95f73b705369551c0 Mon Sep 17 00:00:00 2001 From: Mark Lubin Date: Mon, 6 Apr 2026 19:42:39 -0700 Subject: [PATCH 1/6] Typed Agent protocol + SynixLLMAgent + workspace config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace generic write(AgentRequest) gateway with typed operations matching pipeline transform shapes: - Agent Protocol: map(Artifact)→str, reduce(list[Artifact])→str, group(list[Artifact])→list[Group], fold(accumulated, Artifact, step, total)→str - agent_id (stable identity) separate from fingerprint_value() (config snapshot) - SynixLLMAgent: built-in implementation backed by PromptStore for versioned instructions, LLMClient for execution, system+user messages - Group dataclass: key + artifacts + content for group results - Workspace [agents.*] config parsing, load_agents() convenience - All 4 generic transforms updated for typed calls - 84 agent tests + 2214 total tests passing --- src/synix/__init__.py | 2 +- src/synix/agents.py | 201 ++++++++-- src/synix/core/models.py | 1 + src/synix/ext/fold_synthesis.py | 34 +- src/synix/ext/group_synthesis.py | 66 ++-- src/synix/ext/map_synthesis.py | 28 +- src/synix/ext/reduce_synthesis.py | 29 +- src/synix/workspace.py | 135 +++++++ tests/e2e/test_agent_pipeline.py | 21 +- tests/unit/test_agent_transforms.py | 252 +++++++++--- tests/unit/test_agents.py | 568 ++++++++++++++++++++++++---- 11 files changed, 1087 insertions(+), 250 deletions(-) diff --git a/src/synix/__init__.py b/src/synix/__init__.py index 3fc2272..3f09ef3 100644 --- a/src/synix/__init__.py +++ b/src/synix/__init__.py @@ -2,7 +2,7 @@ __version__ = "0.22.2" -from synix.agents import Agent, AgentRequest, AgentResult # noqa: F401 +from synix.agents import Agent, Group, SynixLLMAgent # noqa: F401 from synix.core.models import ( # noqa: F401 Artifact, FlatFile, diff --git a/src/synix/agents.py b/src/synix/agents.py index a2dfd2c..f9040a2 100644 --- a/src/synix/agents.py +++ b/src/synix/agents.py @@ -1,62 +1,191 @@ -"""Agent gateway interface for Synix transforms. +"""Agent interface for Synix pipeline operations. -Synix owns prompt rendering and transform semantics. The Agent protocol -is an execution gateway --- the transform renders the prompt, the agent -produces the output text. +Agents are named execution units with typed methods matching transform +shapes (map, reduce, group, fold). Each agent has stable identity +(agent_id) and a config snapshot fingerprint for cache invalidation. -The Agent does NOT own prompts, grouping, sorting, fold checkpointing, -search-surface access, or context_budget logic. Those stay in transforms. +SynixLLMAgent is the built-in implementation backed by PromptStore +for versioned instructions and LLMClient for execution. """ from __future__ import annotations from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Protocol, runtime_checkable +from synix.core.models import Artifact -@dataclass(frozen=True) -class AgentRequest: - """A rendered synthesis request ready for execution. - - The transform has already rendered the prompt. The agent just needs - to produce output text. - """ - - prompt: str - max_tokens: int | None = None - metadata: dict[str, Any] = field(default_factory=dict) +@dataclass +class Group: + """Result of a group operation.""" -@dataclass(frozen=True) -class AgentResult: - """Output from an agent execution.""" - + key: str + artifacts: list[Artifact] content: str @runtime_checkable class Agent(Protocol): - """Execution gateway for synthesis transforms. + """Named execution agent for Synix pipeline operations. - Any object with write() and fingerprint_value() methods satisfies - this protocol. Synix does not own the agent's lifecycle, config, - or runtime surface. + agent_id is stable identity (who this agent is). + fingerprint_value() is config snapshot (how it behaves now). + These have separate lifecycles. """ - def write(self, request: AgentRequest) -> AgentResult: - """Execute a rendered synthesis request and return output text.""" + @property + def agent_id(self) -> str: + """Stable identity. Changes only when the agent is replaced.""" ... def fingerprint_value(self) -> str: - """Return a deterministic fingerprint for output-affecting behavior. + """Config snapshot hash. Changes when instructions/model/config change. + Drives cache invalidation.""" + ... - This is a cache-correctness contract. The fingerprint must: - - Be deterministic for the same effective behavior - - Change whenever output-affecting behavior changes - - Be safe to use as part of transform fingerprinting + def map(self, artifact: Artifact) -> str: + """1:1 — process single artifact.""" + ... + + def reduce(self, artifacts: list[Artifact]) -> str: + """N:1 — combine artifacts into one.""" + ... - Examples of what an implementation may include: - model/version, instructions, tool set, endpoint revision, - decoding parameters, externally managed prompt version. - """ + def group(self, artifacts: list[Artifact]) -> list[Group]: + """N:M — assign artifacts to groups and synthesize each.""" ... + + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: + """Sequential — one fold step.""" + ... + + +@dataclass +class SynixLLMAgent: + """Built-in agent backed by PromptStore + LLMClient. + + Instructions are loaded from PromptStore by prompt_key at call time, + so edits in the viewer are picked up automatically. fingerprint_value() + uses the prompt store's content hash, so cache invalidates on edit. + """ + + name: str + prompt_key: str + llm_config: dict | None = None + description: str = "" + _prompt_store: Any = field(default=None, repr=False) + + def __post_init__(self): + if not self.name: + raise ValueError("SynixLLMAgent must have a name") + if not self.prompt_key: + raise ValueError("SynixLLMAgent must have a prompt_key") + + @property + def agent_id(self) -> str: + return self.name + + @property + def instructions(self) -> str: + """Load current instructions from PromptStore.""" + if self._prompt_store is None: + raise ValueError( + f"Agent {self.name!r} has no prompt store — call bind_prompt_store() first" + ) + content = self._prompt_store.get(self.prompt_key) + if content is None: + raise ValueError(f"Prompt key {self.prompt_key!r} not found in store") + return content + + def fingerprint_value(self) -> str: + """Hash of prompt content (from store) + llm_config.""" + from synix.build.fingerprint import compute_digest, fingerprint_value + + content_hash = "" + if self._prompt_store is not None: + content_hash = self._prompt_store.content_hash(self.prompt_key) or "" + + components = {"prompt_content": content_hash} + if self.llm_config: + components["llm_config"] = fingerprint_value(self.llm_config) + return compute_digest(components) + + def bind_prompt_store(self, store) -> SynixLLMAgent: + """Bind a PromptStore. Returns self for chaining.""" + self._prompt_store = store + return self + + def map(self, artifact: Artifact) -> str: + from synix.ext._render import render_template + + rendered = render_template( + self.instructions, + artifact=artifact.content, + label=artifact.label, + artifact_type=artifact.artifact_type, + ) + return self._call(rendered) + + def reduce(self, artifacts: list[Artifact]) -> str: + from synix.ext._render import render_template + + joined = "\n---\n".join(f"### {a.label}\n{a.content}" for a in artifacts) + rendered = render_template( + self.instructions, + artifacts=joined, + count=str(len(artifacts)), + ) + return self._call(rendered) + + def group(self, artifacts: list[Artifact]) -> list[Group]: + raise NotImplementedError( + f"SynixLLMAgent {self.name!r} does not implement group(). " + "See issue #127 for agent-driven grouping." + ) + + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: + from synix.ext._render import render_template + + rendered = render_template( + self.instructions, + accumulated=accumulated, + artifact=artifact.content, + label=artifact.label, + step=str(step), + total=str(total), + ) + return self._call(rendered) + + def _call(self, user_content: str) -> str: + """Execute an LLM call with instructions as system prompt.""" + from synix.build.llm_client import LLMClient + from synix.core.config import LLMConfig + + config = LLMConfig.from_dict(self.llm_config or {}) + client = LLMClient(config) + response = client.complete( + messages=[ + {"role": "system", "content": self.instructions}, + {"role": "user", "content": user_content}, + ], + artifact_desc=f"agent:{self.name}", + ) + return response.content + + @classmethod + def from_file( + cls, + name: str, + prompt_key: str, + instructions_path: str | Path, + prompt_store, + **kwargs, + ) -> SynixLLMAgent: + """Create agent and seed its instructions into the PromptStore from a file.""" + content = Path(instructions_path).read_text() + prompt_store.put(prompt_key, content) + agent = cls(name=name, prompt_key=prompt_key, **kwargs) + agent.bind_prompt_store(prompt_store) + return agent diff --git a/src/synix/core/models.py b/src/synix/core/models.py index 9a2678a..d1df75c 100644 --- a/src/synix/core/models.py +++ b/src/synix/core/models.py @@ -35,6 +35,7 @@ class Artifact: artifact_id: str = "" input_ids: list[str] = field(default_factory=list) prompt_id: str | None = None + agent_id: str | None = None agent_fingerprint: str | None = None model_config: dict | None = None created_at: datetime = field(default_factory=datetime.now) diff --git a/src/synix/ext/fold_synthesis.py b/src/synix/ext/fold_synthesis.py index b99187d..64fcaa4 100644 --- a/src/synix/ext/fold_synthesis.py +++ b/src/synix/ext/fold_synthesis.py @@ -148,10 +148,12 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac client = _get_llm_client(ctx) model_config = ctx.llm_config agent_fingerprint = None + agent_id_val = None else: client = None model_config = None agent_fingerprint = self.agent.fingerprint_value() + agent_id_val = self.agent.agent_id sorted_inputs = self._sort_inputs(inputs) transform_fp = self.compute_fingerprint(ctx.to_dict() if hasattr(ctx, "to_dict") else ctx) @@ -175,30 +177,17 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac # --- Main fold loop (over new_inputs only) --- for step, inp in enumerate(new_inputs, start_step + 1): - rendered = render_template( - self.prompt, - accumulated=accumulated, - artifact=inp.content, - label=inp.label, - step=str(step), - total=str(total), - ) - if self.agent is not None: - from synix.agents import AgentRequest - - result = self.agent.write(AgentRequest( - prompt=rendered, - metadata={ - "transform_name": self.name, - "shape": "fold", - "step": step, - "total": total, - "input_label": inp.label, - }, - )) - accumulated = result.content + accumulated = self.agent.fold(accumulated, inp, step, total) else: + rendered = render_template( + self.prompt, + accumulated=accumulated, + artifact=inp.content, + label=inp.label, + step=str(step), + total=str(total), + ) response = _logged_complete( client, ctx, @@ -229,6 +218,7 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac content=accumulated, input_ids=[a.artifact_id for a in inputs], prompt_id=prompt_id, + agent_id=agent_id_val, model_config=model_config, agent_fingerprint=agent_fingerprint, metadata=output_metadata, diff --git a/src/synix/ext/group_synthesis.py b/src/synix/ext/group_synthesis.py index d66d22d..d1d64f9 100644 --- a/src/synix/ext/group_synthesis.py +++ b/src/synix/ext/group_synthesis.py @@ -172,16 +172,37 @@ def estimate_output_count(self, input_count: int) -> int: def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifact]: ctx = self.get_context(ctx) + prompt_id = self._make_prompt_id() + + # Agent path: agent owns grouping, rendering, and execution + if self.agent is not None: + groups = self.agent.group(inputs) + results: list[Artifact] = [] + for g in groups: + label = f"{self.label_prefix}-{g.key}" if self.label_prefix else g.key + meta = {"group_key": g.key, "input_count": len(g.artifacts)} + results.append(Artifact( + label=label, + artifact_type=self.artifact_type, + content=g.content, + input_ids=[a.artifact_id for a in g.artifacts], + prompt_id=prompt_id, + agent_id=self.agent.agent_id, + agent_fingerprint=self.agent.fingerprint_value(), + model_config=None, + metadata=meta, + )) + return results + + # Non-agent path: use split/group logic with LLM group_key = ctx.get("_group_key") if group_key is None: # Called directly without split — process all groups sequentially - results: list[Artifact] = [] + results = [] for unit_inputs, config_extras in self.split(inputs, ctx): results.extend(self.execute(unit_inputs, ctx.with_updates(config_extras))) return results - prompt_id = self._make_prompt_id() - # Sort inputs by artifact_id for deterministic prompt -> stable cassette key sorted_inputs = sorted(inputs, key=lambda a: a.artifact_id) artifacts_text = "\n\n---\n\n".join(f"### {a.label}\n{a.content}" for a in sorted_inputs) @@ -194,33 +215,14 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac artifact_type=self.artifact_type, ) - if self.agent is not None: - from synix.agents import AgentRequest - - result = self.agent.write(AgentRequest( - prompt=rendered, - metadata={ - "transform_name": self.name, - "shape": "group", - "group_key": group_key, - "input_labels": [a.label for a in inputs], - "count": len(inputs), - }, - )) - content = result.content - model_config = None - agent_fingerprint = self.agent.fingerprint_value() - else: - client = _get_llm_client(ctx) - response = _logged_complete( - client, - ctx, - messages=[{"role": "user", "content": rendered}], - artifact_desc=f"{self.name} group-{group_key}", - ) - content = response.content - model_config = ctx.llm_config - agent_fingerprint = None + client = _get_llm_client(ctx) + response = _logged_complete( + client, + ctx, + messages=[{"role": "user", "content": rendered}], + artifact_desc=f"{self.name} group-{group_key}", + ) + content = response.content prefix = self.label_prefix or (self.group_by if isinstance(self.group_by, str) else self.name) slug = group_key.lower().replace(" ", "-") @@ -237,8 +239,8 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac content=content, input_ids=[a.artifact_id for a in inputs], prompt_id=prompt_id, - model_config=model_config, - agent_fingerprint=agent_fingerprint, + model_config=ctx.llm_config, + agent_fingerprint=None, metadata=output_metadata, ) ] diff --git a/src/synix/ext/map_synthesis.py b/src/synix/ext/map_synthesis.py index 4158014..a7ce2cc 100644 --- a/src/synix/ext/map_synthesis.py +++ b/src/synix/ext/map_synthesis.py @@ -107,29 +107,19 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac prompt_id = self._make_prompt_id() inp = inputs[0] - rendered = render_template( - self.prompt, - artifact=inp.content, - label=inp.label, - artifact_type=inp.artifact_type, - ) if self.agent is not None: - from synix.agents import AgentRequest - - result = self.agent.write(AgentRequest( - prompt=rendered, - metadata={ - "transform_name": self.name, - "shape": "map", - "input_labels": [inp.label], - "artifact_type": self.artifact_type, - }, - )) - content = result.content + content = self.agent.map(inp) # agent owns rendering + execution model_config = None agent_fingerprint = self.agent.fingerprint_value() + agent_id_val = self.agent.agent_id else: + rendered = render_template( + self.prompt, + artifact=inp.content, + label=inp.label, + artifact_type=inp.artifact_type, + ) client = _get_llm_client(ctx) response = _logged_complete( client, @@ -140,6 +130,7 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac content = response.content model_config = ctx.llm_config agent_fingerprint = None + agent_id_val = None if self.label_fn is not None: label = self.label_fn(inp) @@ -159,6 +150,7 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac content=content, input_ids=[inp.artifact_id], prompt_id=prompt_id, + agent_id=agent_id_val, model_config=model_config, agent_fingerprint=agent_fingerprint, metadata=output_metadata, diff --git a/src/synix/ext/reduce_synthesis.py b/src/synix/ext/reduce_synthesis.py index 6693070..67ffa74 100644 --- a/src/synix/ext/reduce_synthesis.py +++ b/src/synix/ext/reduce_synthesis.py @@ -112,30 +112,19 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac # Sort inputs by artifact_id for deterministic prompt -> stable cassette key sorted_inputs = sorted(inputs, key=lambda a: a.artifact_id) - artifacts_text = "\n\n---\n\n".join(f"### {a.label}\n{a.content}" for a in sorted_inputs) - - rendered = render_template( - self.prompt, - artifacts=artifacts_text, - count=str(len(inputs)), - ) if self.agent is not None: - from synix.agents import AgentRequest - - result = self.agent.write(AgentRequest( - prompt=rendered, - metadata={ - "transform_name": self.name, - "shape": "reduce", - "input_labels": [a.label for a in inputs], - "count": len(inputs), - }, - )) - content = result.content + content = self.agent.reduce(sorted_inputs) # agent owns rendering + execution model_config = None agent_fingerprint = self.agent.fingerprint_value() + agent_id_val = self.agent.agent_id else: + artifacts_text = "\n\n---\n\n".join(f"### {a.label}\n{a.content}" for a in sorted_inputs) + rendered = render_template( + self.prompt, + artifacts=artifacts_text, + count=str(len(inputs)), + ) client = _get_llm_client(ctx) response = _logged_complete( client, @@ -146,6 +135,7 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac content = response.content model_config = ctx.llm_config agent_fingerprint = None + agent_id_val = None output_metadata = {"input_count": len(inputs)} if self.metadata_fn is not None: @@ -158,6 +148,7 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac content=content, input_ids=[a.artifact_id for a in inputs], prompt_id=prompt_id, + agent_id=agent_id_val, model_config=model_config, agent_fingerprint=agent_fingerprint, metadata=output_metadata, diff --git a/src/synix/workspace.py b/src/synix/workspace.py index ebe12cf..33f66d8 100644 --- a/src/synix/workspace.py +++ b/src/synix/workspace.py @@ -56,6 +56,22 @@ class VLLMConfig: startup_timeout: int = 300 +@dataclass +class AgentConfig: + """Agent definition from synix.toml [agents.*] section.""" + + name: str + prompt_key: str = "" # key in PromptStore; defaults to name + instructions_file: str = "" # path to seed instructions from + provider: str = "" + model: str = "" + base_url: str | None = None + api_key_env: str | None = None + temperature: float | None = 0.3 + max_tokens: int = 4096 + description: str = "" + + @dataclass class WorkspaceConfig: """Parsed workspace configuration from synix.toml.""" @@ -63,6 +79,7 @@ class WorkspaceConfig: name: str = "" pipeline_path: str = "pipeline.py" buckets: list[BucketConfig] = field(default_factory=list) + agents: dict[str, AgentConfig] = field(default_factory=dict) auto_build: BuildQueueConfig = field(default_factory=BuildQueueConfig) vllm: VLLMConfig = field(default_factory=VLLMConfig) @@ -208,6 +225,53 @@ def config(self) -> WorkspaceConfig: def buckets(self) -> list[BucketConfig]: return self._config.buckets + @property + def agents(self) -> dict[str, AgentConfig]: + return self._config.agents + + def get_agent(self, name: str): + """Load a configured agent by name, bound to the workspace's PromptStore.""" + from synix.agents import SynixLLMAgent + + if name not in self._config.agents: + available = list(self._config.agents.keys()) + raise ValueError(f"Agent {name!r} not found. Available: {available}") + + ac = self._config.agents[name] + llm_config = {} + if ac.provider: + llm_config["provider"] = ac.provider + if ac.model: + llm_config["model"] = ac.model + if ac.base_url: + llm_config["base_url"] = ac.base_url + if ac.api_key_env: + llm_config["api_key_env"] = ac.api_key_env + if ac.temperature is not None: + llm_config["temperature"] = ac.temperature + llm_config["max_tokens"] = ac.max_tokens + + agent = SynixLLMAgent( + name=ac.name, + prompt_key=ac.prompt_key or ac.name, + llm_config=llm_config or None, + description=ac.description, + ) + + # Bind prompt store if runtime is active + if self._runtime and self._runtime.prompt_store: + agent.bind_prompt_store(self._runtime.prompt_store) + + # Seed instructions from file if specified and not already in store + if ac.instructions_file: + instructions_path = self.root / ac.instructions_file + if instructions_path.exists(): + store = self._runtime.prompt_store + if store.get(agent.prompt_key) is None: + store.put(agent.prompt_key, instructions_path.read_text()) + + return agent + def bucket_dir(self, name: str) -> Path: """Resolve a bucket's directory to an absolute path.""" for b in self._config.buckets: @@ -374,15 +438,86 @@ def _parse_toml(path: Path, project_root: Path) -> WorkspaceConfig: vllm_kwargs[key] = val vllm = VLLMConfig(**vllm_kwargs) + # Agents + agents: dict[str, AgentConfig] = {} + for aname, agent_raw in raw.get("agents", {}).items(): + agents[aname] = AgentConfig( + name=aname, + prompt_key=agent_raw.get("prompt_key", aname), + instructions_file=agent_raw.get("instructions_file", ""), + provider=agent_raw.get("provider", ""), + model=agent_raw.get("model", ""), + base_url=agent_raw.get("base_url"), + api_key_env=agent_raw.get("api_key_env"), + temperature=agent_raw.get("temperature", 0.3), + max_tokens=int(agent_raw.get("max_tokens", 4096)), + description=agent_raw.get("description", ""), + ) + return WorkspaceConfig( name=name, pipeline_path=pipeline_path, buckets=buckets, + agents=agents, auto_build=auto_build, vllm=vllm, ) +def load_agents(config_path: str | Path | None = None) -> dict: + """Load agents from synix.toml. For use in pipeline.py files. + + Returns a dict of agent_name → SynixLLMAgent, with PromptStore + bound if .synix/prompts.db exists alongside the config file. + """ + from synix.agents import SynixLLMAgent + from synix.server.prompt_store import PromptStore + + path = Path(config_path) if config_path else Path("synix.toml") + if not path.exists(): + return {} + + config = _parse_toml(path, path.parent) + if not config or not config.agents: + return {} + + # Try to bind prompt store from .synix/prompts.db + prompts_db = path.parent / ".synix" / "prompts.db" + store = PromptStore(prompts_db) if prompts_db.exists() else None + + agents: dict[str, SynixLLMAgent] = {} + for name, ac in config.agents.items(): + llm_config = {} + if ac.provider: + llm_config["provider"] = ac.provider + if ac.model: + llm_config["model"] = ac.model + if ac.base_url: + llm_config["base_url"] = ac.base_url + if ac.temperature is not None: + llm_config["temperature"] = ac.temperature + llm_config["max_tokens"] = ac.max_tokens + + agent = SynixLLMAgent( + name=name, + prompt_key=ac.prompt_key or name, + llm_config=llm_config or None, + description=ac.description, + ) + + if store: + agent.bind_prompt_store(store) + # Seed from file if instructions_file specified + if ac.instructions_file: + instructions_path = path.parent / ac.instructions_file + if instructions_path.exists() and store.get(agent.prompt_key) is None: + store.put(agent.prompt_key, instructions_path.read_text()) + + agents[name] = agent + + return agents + + def load_server_bindings(path: str | Path) -> ServerBindings: """Parse only the [server] section from a config file.""" p = Path(path) diff --git a/tests/e2e/test_agent_pipeline.py b/tests/e2e/test_agent_pipeline.py index 89d4bf4..a26ba00 100644 --- a/tests/e2e/test_agent_pipeline.py +++ b/tests/e2e/test_agent_pipeline.py @@ -13,7 +13,8 @@ import synix from synix import Pipeline, Source -from synix.agents import AgentRequest, AgentResult +from synix.agents import Group +from synix.core.models import Artifact from synix.sdk import BuildResult from synix.transforms import MapSynthesis, ReduceSynthesis @@ -29,12 +30,26 @@ def __init__(self, prefix: str = "AGENT:", fingerprint: str = "test-agent-v1"): self._prefix = prefix self._fingerprint = fingerprint - def write(self, request: AgentRequest) -> AgentResult: - return AgentResult(content=f"{self._prefix} processed {len(request.prompt)} chars") + @property + def agent_id(self) -> str: + return f"deterministic-{self._fingerprint}" def fingerprint_value(self) -> str: return self._fingerprint + def map(self, artifact: Artifact) -> str: + return f"{self._prefix} processed {len(artifact.content)} chars" + + def reduce(self, artifacts: list[Artifact]) -> str: + total_chars = sum(len(a.content) for a in artifacts) + return f"{self._prefix} reduced {len(artifacts)} artifacts ({total_chars} chars)" + + def group(self, artifacts: list[Artifact]) -> list[Group]: + return [Group(key="all", artifacts=artifacts, content=f"{self._prefix} grouped")] + + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: + return f"{self._prefix} fold step {step}/{total}" + # --------------------------------------------------------------------------- # Fixtures diff --git a/tests/unit/test_agent_transforms.py b/tests/unit/test_agent_transforms.py index f306860..12c861d 100644 --- a/tests/unit/test_agent_transforms.py +++ b/tests/unit/test_agent_transforms.py @@ -5,7 +5,7 @@ import pytest from synix import Artifact -from synix.agents import Agent, AgentRequest, AgentResult +from synix.agents import Agent, Group from synix.transforms import FoldSynthesis, GroupSynthesis, MapSynthesis, ReduceSynthesis # --------------------------------------------------------------------------- @@ -16,18 +16,39 @@ class FakeAgent: """Test double satisfying the Agent protocol.""" - def __init__(self, response: str = "agent output", fingerprint: str = "test-agent-fp"): + def __init__(self, response: str = "agent output", fingerprint: str = "test-agent-fp", agent_id: str = "test-agent"): self._response = response self._fingerprint = fingerprint - self.calls: list[AgentRequest] = [] + self._agent_id = agent_id + self.map_calls: list[Artifact] = [] + self.reduce_calls: list[list[Artifact]] = [] + self.group_calls: list[list[Artifact]] = [] + self.fold_calls: list[tuple[str, Artifact, int, int]] = [] - def write(self, request: AgentRequest) -> AgentResult: - self.calls.append(request) - return AgentResult(content=self._response) + @property + def agent_id(self) -> str: + return self._agent_id def fingerprint_value(self) -> str: return self._fingerprint + def map(self, artifact: Artifact) -> str: + self.map_calls.append(artifact) + return self._response + + def reduce(self, artifacts: list[Artifact]) -> str: + self.reduce_calls.append(artifacts) + return self._response + + def group(self, artifacts: list[Artifact]) -> list[Group]: + self.group_calls.append(artifacts) + # Return one group per artifact for testing + return [Group(key=a.label, artifacts=[a], content=self._response) for a in artifacts] + + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: + self.fold_calls.append((accumulated, artifact, step, total)) + return self._response + def _make_artifact(label: str, content: str = "content", **metadata) -> Artifact: return Artifact( @@ -44,7 +65,7 @@ def _make_artifact(label: str, content: str = "content", **metadata) -> Artifact class TestMapWithAgent: - def test_agent_write_called_with_rendered_prompt(self): + def test_agent_map_called_with_artifact(self): agent = FakeAgent() t = MapSynthesis( "ws", @@ -55,13 +76,9 @@ def test_agent_write_called_with_rendered_prompt(self): inp = _make_artifact("bio-alice", "Alice is an engineer.") t.execute([inp], {"llm_config": {}}) - assert len(agent.calls) == 1 - req = agent.calls[0] - assert "Alice is an engineer." in req.prompt - assert req.metadata["transform_name"] == "ws" - assert req.metadata["shape"] == "map" - assert req.metadata["input_labels"] == ["bio-alice"] - assert req.metadata["artifact_type"] == "analysis" + assert len(agent.map_calls) == 1 + assert agent.map_calls[0].label == "bio-alice" + assert agent.map_calls[0].content == "Alice is an engineer." def test_artifact_has_agent_fingerprint(self): agent = FakeAgent(fingerprint="map-fp-123") @@ -71,6 +88,14 @@ def test_artifact_has_agent_fingerprint(self): assert results[0].agent_fingerprint == "map-fp-123" + def test_artifact_has_agent_id(self): + agent = FakeAgent(agent_id="map-agent-1") + t = MapSynthesis("ws", prompt="Analyze: {artifact}", agent=agent) + inp = _make_artifact("bio-alice") + results = t.execute([inp], {"llm_config": {}}) + + assert results[0].agent_id == "map-agent-1" + def test_artifact_content_from_agent(self): agent = FakeAgent(response="agent-generated analysis") t = MapSynthesis("ws", prompt="Analyze: {artifact}", agent=agent) @@ -103,7 +128,7 @@ def test_prompt_id_still_set(self): class TestReduceWithAgent: - def test_agent_write_called(self): + def test_agent_reduce_called(self): agent = FakeAgent() t = ReduceSynthesis( "team", @@ -115,12 +140,11 @@ def test_agent_write_called(self): inputs = [_make_artifact(f"ws-{i}", f"profile {i}") for i in range(3)] results = t.execute(inputs, {"llm_config": {}}) - assert len(agent.calls) == 1 - req = agent.calls[0] - assert req.metadata["shape"] == "reduce" - assert req.metadata["count"] == 3 - assert len(req.metadata["input_labels"]) == 3 + assert len(agent.reduce_calls) == 1 + # Reduce receives sorted artifacts + assert len(agent.reduce_calls[0]) == 3 assert results[0].agent_fingerprint == "test-agent-fp" + assert results[0].agent_id == "test-agent" assert results[0].model_config is None def test_artifact_content_from_agent(self): @@ -131,6 +155,14 @@ def test_artifact_content_from_agent(self): assert results[0].content == "reduced output" + def test_artifact_has_agent_id(self): + agent = FakeAgent(agent_id="reduce-agent-1") + t = ReduceSynthesis("r", prompt="{artifacts}", label="out", agent=agent) + inputs = [_make_artifact(f"a-{i}") for i in range(2)] + results = t.execute(inputs, {"llm_config": {}}) + + assert results[0].agent_id == "reduce-agent-1" + # --------------------------------------------------------------------------- # GroupSynthesis + Agent @@ -138,7 +170,7 @@ def test_artifact_content_from_agent(self): class TestGroupWithAgent: - def test_agent_called_per_group(self): + def test_agent_group_called_with_all_inputs(self): agent = FakeAgent() inputs = [ _make_artifact("ep-1", "content 1", team="alpha"), @@ -154,19 +186,14 @@ def test_agent_called_per_group(self): ) results = t.execute(inputs, {"llm_config": {}}) - # Two groups -> two agent calls - assert len(agent.calls) == 2 - assert len(results) == 2 - - # Check metadata on each call - shapes = {call.metadata["group_key"] for call in agent.calls} - assert shapes == {"alpha", "beta"} - for call in agent.calls: - assert call.metadata["shape"] == "group" - assert call.metadata["transform_name"] == "team-summaries" + # Agent.group() called once with all inputs + assert len(agent.group_calls) == 1 + assert len(agent.group_calls[0]) == 3 + # FakeAgent returns one group per artifact -> 3 results + assert len(results) == 3 - def test_artifacts_have_agent_fingerprint(self): - agent = FakeAgent(fingerprint="group-fp") + def test_artifacts_have_agent_fingerprint_and_id(self): + agent = FakeAgent(fingerprint="group-fp", agent_id="group-agent-1") inputs = [_make_artifact("ep-1", team="alpha")] t = GroupSynthesis( "s", @@ -177,8 +204,48 @@ def test_artifacts_have_agent_fingerprint(self): results = t.execute(inputs, {"llm_config": {}}) assert results[0].agent_fingerprint == "group-fp" + assert results[0].agent_id == "group-agent-1" assert results[0].model_config is None + def test_group_label_uses_prefix(self): + agent = FakeAgent() + inputs = [_make_artifact("ep-1", team="alpha")] + t = GroupSynthesis( + "s", + group_by="team", + prompt="{artifacts}", + agent=agent, + label_prefix="team", + ) + results = t.execute(inputs, {"llm_config": {}}) + assert results[0].label == "team-ep-1" + + def test_group_label_without_prefix_uses_key(self): + agent = FakeAgent() + inputs = [_make_artifact("ep-1", team="alpha")] + t = GroupSynthesis( + "s", + group_by="team", + prompt="{artifacts}", + agent=agent, + ) + results = t.execute(inputs, {"llm_config": {}}) + # FakeAgent returns group key = artifact label + assert results[0].label == "ep-1" + + def test_group_metadata_contains_key_and_count(self): + agent = FakeAgent() + inputs = [_make_artifact("ep-1", team="alpha")] + t = GroupSynthesis( + "s", + group_by="team", + prompt="{artifacts}", + agent=agent, + ) + results = t.execute(inputs, {"llm_config": {}}) + assert results[0].metadata["group_key"] == "ep-1" + assert results[0].metadata["input_count"] == 1 + # --------------------------------------------------------------------------- # FoldSynthesis + Agent @@ -200,19 +267,17 @@ def test_agent_called_per_step(self): results = t.execute(inputs, {"llm_config": {}}) # One call per input - assert len(agent.calls) == 3 + assert len(agent.fold_calls) == 3 assert len(results) == 1 assert results[0].content == "accumulated" - # Verify step metadata - steps = [call.metadata["step"] for call in agent.calls] - assert steps == [1, 2, 3] - for call in agent.calls: - assert call.metadata["shape"] == "fold" - assert call.metadata["total"] == 3 + # Verify step/total in each call + for i, (acc, art, step, total) in enumerate(agent.fold_calls): + assert step == i + 1 + assert total == 3 - def test_artifact_has_agent_fingerprint(self): - agent = FakeAgent(fingerprint="fold-fp") + def test_artifact_has_agent_fingerprint_and_id(self): + agent = FakeAgent(fingerprint="fold-fp", agent_id="fold-agent-1") inputs = [_make_artifact("ep-0")] t = FoldSynthesis( "fold", @@ -223,6 +288,7 @@ def test_artifact_has_agent_fingerprint(self): results = t.execute(inputs, {"llm_config": {}}) assert results[0].agent_fingerprint == "fold-fp" + assert results[0].agent_id == "fold-agent-1" assert results[0].model_config is None def test_fold_checkpoint_still_written(self): @@ -241,6 +307,24 @@ def test_fold_checkpoint_still_written(self): assert cp["version"] == 1 assert len(cp["seen_inputs"]) == 2 + def test_fold_initial_passed_to_first_step(self): + agent = FakeAgent(response="step-result") + inputs = [_make_artifact("ep-0")] + t = FoldSynthesis( + "fold", + prompt="{accumulated}\n{artifact}", + initial="INITIAL VALUE", + label="out", + agent=agent, + ) + t.execute(inputs, {"llm_config": {}}) + + # First call should receive the initial value as accumulated + acc, _art, step, total = agent.fold_calls[0] + assert acc == "INITIAL VALUE" + assert step == 1 + assert total == 1 + # --------------------------------------------------------------------------- # Prompt still required @@ -274,19 +358,37 @@ class TestEmptyFingerprintRejected: def test_map_rejects_empty_fingerprint(self): class EmptyFpAgent: - def write(self, request): - return AgentResult(content="x") + @property + def agent_id(self): + return "empty" def fingerprint_value(self): return "" + def map(self, artifact): + return "x" + def reduce(self, artifacts): + return "x" + def group(self, artifacts): + return [] + def fold(self, accumulated, artifact, step, total): + return "x" with pytest.raises(ValueError, match="empty fingerprint"): MapSynthesis("m", prompt="p", agent=EmptyFpAgent()) def test_fold_rejects_empty_fingerprint(self): class EmptyFpAgent: - def write(self, request): - return AgentResult(content="x") + @property + def agent_id(self): + return "empty" def fingerprint_value(self): return "" + def map(self, artifact): + return "x" + def reduce(self, artifacts): + return "x" + def group(self, artifacts): + return [] + def fold(self, accumulated, artifact, step, total): + return "x" with pytest.raises(ValueError, match="empty fingerprint"): FoldSynthesis("f", prompt="p", label="out", agent=EmptyFpAgent()) @@ -304,6 +406,7 @@ def test_map_without_agent(self, mock_llm): assert len(results) == 1 assert results[0].agent_fingerprint is None + assert results[0].agent_id is None assert len(mock_llm) == 1 def test_reduce_without_agent(self, mock_llm): @@ -312,6 +415,7 @@ def test_reduce_without_agent(self, mock_llm): results = t.execute(inputs, {"llm_config": {}}) assert results[0].agent_fingerprint is None + assert results[0].agent_id is None assert len(mock_llm) == 1 def test_group_without_agent(self, mock_llm): @@ -320,6 +424,7 @@ def test_group_without_agent(self, mock_llm): results = t.execute(inputs, {"llm_config": {}}) assert results[0].agent_fingerprint is None + assert results[0].agent_id is None assert len(mock_llm) == 1 def test_fold_without_agent(self, mock_llm): @@ -328,6 +433,7 @@ def test_fold_without_agent(self, mock_llm): results = t.execute(inputs, {"llm_config": {}}) assert results[0].agent_fingerprint is None + assert results[0].agent_id is None assert len(mock_llm) == 1 @@ -418,6 +524,42 @@ def test_fold_artifact_agent_fingerprint(self): assert results[0].agent_fingerprint == "artifact-fp" +# --------------------------------------------------------------------------- +# Agent ID on artifact +# --------------------------------------------------------------------------- + + +class TestAgentIdOnArtifact: + def test_map_artifact_agent_id(self): + agent = FakeAgent(agent_id="map-agent") + t = MapSynthesis("ws", prompt="{artifact}", agent=agent) + results = t.execute([_make_artifact("a")], {"llm_config": {}}) + assert results[0].agent_id == "map-agent" + + def test_reduce_artifact_agent_id(self): + agent = FakeAgent(agent_id="reduce-agent") + t = ReduceSynthesis("r", prompt="{artifacts}", label="out", agent=agent) + results = t.execute([_make_artifact("a")], {"llm_config": {}}) + assert results[0].agent_id == "reduce-agent" + + def test_group_artifact_agent_id(self): + agent = FakeAgent(agent_id="group-agent") + t = GroupSynthesis("g", group_by="team", prompt="{artifacts}", agent=agent) + results = t.execute([_make_artifact("a", team="alpha")], {"llm_config": {}}) + assert results[0].agent_id == "group-agent" + + def test_fold_artifact_agent_id(self): + agent = FakeAgent(agent_id="fold-agent") + t = FoldSynthesis("f", prompt="{accumulated}\n{artifact}", label="out", agent=agent) + results = t.execute([_make_artifact("a")], {"llm_config": {}}) + assert results[0].agent_id == "fold-agent" + + def test_no_agent_id_when_no_agent(self, mock_llm): + t = MapSynthesis("ws", prompt="{artifact}") + results = t.execute([_make_artifact("a")], {"llm_config": {}}) + assert results[0].agent_id is None + + # --------------------------------------------------------------------------- # model_config=None when agent handles execution # --------------------------------------------------------------------------- @@ -459,12 +601,25 @@ def test_minimal_agent_protocol(self): """A minimal class satisfying the Agent protocol works with transforms.""" class MinimalAgent: - def write(self, request: AgentRequest) -> AgentResult: - return AgentResult(content=f"processed: {request.prompt[:20]}") + @property + def agent_id(self) -> str: + return "minimal" def fingerprint_value(self) -> str: return "minimal-v1" + def map(self, artifact: Artifact) -> str: + return f"processed: {artifact.content[:20]}" + + def reduce(self, artifacts: list[Artifact]) -> str: + return "reduced" + + def group(self, artifacts: list[Artifact]) -> list[Group]: + return [Group(key="all", artifacts=artifacts, content="grouped")] + + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: + return f"{accumulated}+{artifact.label}" + assert isinstance(MinimalAgent(), Agent) agent = MinimalAgent() @@ -473,3 +628,4 @@ def fingerprint_value(self) -> str: assert results[0].content.startswith("processed:") assert results[0].agent_fingerprint == "minimal-v1" + assert results[0].agent_id == "minimal" diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py index 53a3e86..062b8ff 100644 --- a/tests/unit/test_agents.py +++ b/tests/unit/test_agents.py @@ -1,57 +1,67 @@ -"""Tests for the Agent gateway interface.""" +"""Tests for the Agent protocol and SynixLLMAgent implementation.""" from __future__ import annotations +from unittest.mock import MagicMock, patch + import pytest -from synix.agents import Agent, AgentRequest, AgentResult +from synix.agents import Agent, Group, SynixLLMAgent +from synix.core.models import Artifact + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- class FakeAgent: """Minimal Agent protocol implementation for testing.""" - def __init__(self, response: str = "fake output", fingerprint: str = "fake-fp-001"): - self._response = response - self._fingerprint = fingerprint + @property + def agent_id(self): + return "fake" - def write(self, request: AgentRequest) -> AgentResult: - return AgentResult(content=self._response) + def fingerprint_value(self): + return "fp" - def fingerprint_value(self) -> str: - return self._fingerprint + def map(self, artifact): + return "mapped" + def reduce(self, artifacts): + return "reduced" -class TestAgentRequest: - def test_construction(self): - req = AgentRequest(prompt="hello") - assert req.prompt == "hello" - assert req.max_tokens is None - assert req.metadata == {} - - def test_with_metadata(self): - req = AgentRequest( - prompt="render this", - max_tokens=500, - metadata={"shape": "map", "input_labels": ["a", "b"]}, - ) - assert req.max_tokens == 500 - assert req.metadata["shape"] == "map" + def group(self, artifacts): + return [Group(key="g", artifacts=artifacts, content="grouped")] + + def fold(self, accumulated, artifact, step, total): + return "folded" - def test_frozen(self): - req = AgentRequest(prompt="hello") - with pytest.raises(AttributeError): - req.prompt = "changed" +def _make_artifact(label: str = "test", content: str = "hello") -> Artifact: + return Artifact(label=label, artifact_type="test", content=content) -class TestAgentResult: + +# --------------------------------------------------------------------------- +# TestGroup +# --------------------------------------------------------------------------- + + +class TestGroup: def test_construction(self): - result = AgentResult(content="output text") - assert result.content == "output text" + arts = [_make_artifact()] + g = Group(key="topic-a", artifacts=arts, content="synthesized") + assert g.key == "topic-a" + assert g.artifacts == arts + assert g.content == "synthesized" + + def test_empty_artifacts(self): + g = Group(key="empty", artifacts=[], content="nothing") + assert g.artifacts == [] - def test_frozen(self): - result = AgentResult(content="output") - with pytest.raises(AttributeError): - result.content = "changed" + +# --------------------------------------------------------------------------- +# TestAgentProtocol +# --------------------------------------------------------------------------- class TestAgentProtocol: @@ -59,53 +69,469 @@ def test_fake_agent_satisfies_protocol(self): agent = FakeAgent() assert isinstance(agent, Agent) - def test_write_returns_result(self): - agent = FakeAgent(response="test output") - result = agent.write(AgentRequest(prompt="input")) - assert result.content == "test output" + def test_fake_agent_methods(self): + agent = FakeAgent() + art = _make_artifact() + assert agent.agent_id == "fake" + assert agent.fingerprint_value() == "fp" + assert agent.map(art) == "mapped" + assert agent.reduce([art]) == "reduced" + assert agent.fold("acc", art, 1, 5) == "folded" + groups = agent.group([art]) + assert len(groups) == 1 + assert groups[0].key == "g" + + def test_missing_map_fails_isinstance(self): + class NoMap: + @property + def agent_id(self): + return "x" + + def fingerprint_value(self): + return "x" + + def reduce(self, artifacts): + return "" + + def group(self, artifacts): + return [] + + def fold(self, accumulated, artifact, step, total): + return "" + + assert not isinstance(NoMap(), Agent) + + def test_missing_fingerprint_fails_isinstance(self): + class NoFingerprint: + @property + def agent_id(self): + return "x" + + def map(self, artifact): + return "" + + def reduce(self, artifacts): + return "" + + def group(self, artifacts): + return [] + + def fold(self, accumulated, artifact, step, total): + return "" + + assert not isinstance(NoFingerprint(), Agent) + + def test_missing_reduce_fails_isinstance(self): + class NoReduce: + @property + def agent_id(self): + return "x" + + def fingerprint_value(self): + return "x" + + def map(self, artifact): + return "" + + def group(self, artifacts): + return [] + + def fold(self, accumulated, artifact, step, total): + return "" - def test_fingerprint_value(self): - agent = FakeAgent(fingerprint="abc123") - assert agent.fingerprint_value() == "abc123" + assert not isinstance(NoReduce(), Agent) - def test_fingerprint_deterministic(self): - agent = FakeAgent(fingerprint="stable") - assert agent.fingerprint_value() == agent.fingerprint_value() + def test_missing_group_fails_isinstance(self): + class NoGroup: + @property + def agent_id(self): + return "x" + + def fingerprint_value(self): + return "x" + + def map(self, artifact): + return "" + + def reduce(self, artifacts): + return "" + + def fold(self, accumulated, artifact, step, total): + return "" + + assert not isinstance(NoGroup(), Agent) + + def test_missing_fold_fails_isinstance(self): + class NoFold: + @property + def agent_id(self): + return "x" - def test_object_without_write_fails_isinstance(self): - class NotAnAgent: - def fingerprint_value(self) -> str: + def fingerprint_value(self): return "x" - assert not isinstance(NotAnAgent(), Agent) + def map(self, artifact): + return "" - def test_object_without_fingerprint_fails_isinstance(self): - class NotAnAgent: - def write(self, request): - return AgentResult(content="x") + def reduce(self, artifacts): + return "" - assert not isinstance(NotAnAgent(), Agent) + def group(self, artifacts): + return [] - def test_different_fingerprints_for_different_agents(self): - a1 = FakeAgent(fingerprint="fp-1") - a2 = FakeAgent(fingerprint="fp-2") - assert a1.fingerprint_value() != a2.fingerprint_value() + assert not isinstance(NoFold(), Agent) -class TestArtifactAgentFingerprint: - def test_artifact_has_agent_fingerprint_field(self): - from synix.core.models import Artifact +# --------------------------------------------------------------------------- +# TestSynixLLMAgent +# --------------------------------------------------------------------------- - art = Artifact(label="test", artifact_type="test", content="hello") - assert art.agent_fingerprint is None - def test_artifact_with_agent_fingerprint(self): - from synix.core.models import Artifact +class TestSynixLLMAgent: + def test_creation(self): + agent = SynixLLMAgent(name="summarizer", prompt_key="summarize") + assert agent.name == "summarizer" + assert agent.prompt_key == "summarize" + assert agent.llm_config is None + assert agent.description == "" - art = Artifact( - label="test", - artifact_type="test", - content="hello", - agent_fingerprint="agent-fp-xyz", + def test_agent_id(self): + agent = SynixLLMAgent(name="my-agent", prompt_key="key") + assert agent.agent_id == "my-agent" + + def test_empty_name_raises(self): + with pytest.raises(ValueError, match="must have a name"): + SynixLLMAgent(name="", prompt_key="key") + + def test_empty_prompt_key_raises(self): + with pytest.raises(ValueError, match="must have a prompt_key"): + SynixLLMAgent(name="agent", prompt_key="") + + def test_with_llm_config(self): + agent = SynixLLMAgent( + name="agent", + prompt_key="key", + llm_config={"model": "claude-sonnet-4-20250514", "temperature": 0.5}, + ) + assert agent.llm_config["model"] == "claude-sonnet-4-20250514" + + def test_with_description(self): + agent = SynixLLMAgent( + name="agent", prompt_key="key", description="Does stuff" ) - assert art.agent_fingerprint == "agent-fp-xyz" + assert agent.description == "Does stuff" + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgentPromptStore +# --------------------------------------------------------------------------- + + +class TestSynixLLMAgentPromptStore: + def test_bind_prompt_store(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("my-prompt", "You are a helpful agent.") + agent = SynixLLMAgent(name="agent", prompt_key="my-prompt") + result = agent.bind_prompt_store(store) + assert result is agent # returns self for chaining + assert agent.instructions == "You are a helpful agent." + store.close() + + def test_instructions_without_store_raises(self): + agent = SynixLLMAgent(name="agent", prompt_key="key") + with pytest.raises(ValueError, match="no prompt store"): + _ = agent.instructions + + def test_instructions_key_not_found_raises(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + agent = SynixLLMAgent(name="agent", prompt_key="missing-key") + agent.bind_prompt_store(store) + with pytest.raises(ValueError, match="not found in store"): + _ = agent.instructions + store.close() + + def test_instructions_picks_up_edits(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("prompt", "Version 1") + agent = SynixLLMAgent(name="agent", prompt_key="prompt") + agent.bind_prompt_store(store) + assert agent.instructions == "Version 1" + + store.put("prompt", "Version 2") + assert agent.instructions == "Version 2" + store.close() + + def test_fingerprint_changes_with_prompt_edit(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("prompt", "Original instructions") + agent = SynixLLMAgent(name="agent", prompt_key="prompt") + agent.bind_prompt_store(store) + + fp1 = agent.fingerprint_value() + store.put("prompt", "Updated instructions") + fp2 = agent.fingerprint_value() + assert fp1 != fp2 + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgentMap +# --------------------------------------------------------------------------- + + +class TestSynixLLMAgentMap: + def test_map_renders_and_calls_llm(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("map-prompt", "Summarize: {artifact}") + agent = SynixLLMAgent(name="mapper", prompt_key="map-prompt") + agent.bind_prompt_store(store) + + mock_response = MagicMock() + mock_response.content = "Summary output" + + with patch("synix.build.llm_client.LLMClient") as MockClient: + instance = MockClient.return_value + instance.complete.return_value = mock_response + + result = agent.map(_make_artifact(content="raw text")) + + assert result == "Summary output" + # Verify LLM was called with system + user messages + call_args = instance.complete.call_args + messages = call_args.kwargs.get("messages") or call_args[1].get("messages") or call_args[0][0] + assert len(messages) == 2 + assert messages[0]["role"] == "system" + assert messages[1]["role"] == "user" + assert "raw text" in messages[1]["content"] + store.close() + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgentReduce +# --------------------------------------------------------------------------- + + +class TestSynixLLMAgentReduce: + def test_reduce_joins_artifacts_and_calls_llm(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("reduce-prompt", "Combine these {count} items:\n{artifacts}") + agent = SynixLLMAgent(name="reducer", prompt_key="reduce-prompt") + agent.bind_prompt_store(store) + + arts = [ + _make_artifact(label="a", content="first"), + _make_artifact(label="b", content="second"), + ] + + mock_response = MagicMock() + mock_response.content = "Combined output" + + with patch("synix.build.llm_client.LLMClient") as MockClient: + instance = MockClient.return_value + instance.complete.return_value = mock_response + + result = agent.reduce(arts) + + assert result == "Combined output" + call_args = instance.complete.call_args + messages = call_args.kwargs.get("messages") or call_args[1].get("messages") or call_args[0][0] + user_content = messages[1]["content"] + assert "first" in user_content + assert "second" in user_content + assert "2" in user_content # count + store.close() + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgentFold +# --------------------------------------------------------------------------- + + +class TestSynixLLMAgentFold: + def test_fold_renders_with_accumulated_and_step(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put( + "fold-prompt", + "Step {step}/{total}. So far: {accumulated}. New: {artifact}", + ) + agent = SynixLLMAgent(name="folder", prompt_key="fold-prompt") + agent.bind_prompt_store(store) + + mock_response = MagicMock() + mock_response.content = "Folded result" + + with patch("synix.build.llm_client.LLMClient") as MockClient: + instance = MockClient.return_value + instance.complete.return_value = mock_response + + result = agent.fold( + accumulated="previous state", + artifact=_make_artifact(content="new item"), + step=3, + total=10, + ) + + assert result == "Folded result" + call_args = instance.complete.call_args + messages = call_args.kwargs.get("messages") or call_args[1].get("messages") or call_args[0][0] + user_content = messages[1]["content"] + assert "3" in user_content + assert "10" in user_content + assert "previous state" in user_content + assert "new item" in user_content + store.close() + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgentGroup +# --------------------------------------------------------------------------- + + +class TestSynixLLMAgentGroup: + def test_group_raises_not_implemented(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("group-prompt", "Group these") + agent = SynixLLMAgent(name="grouper", prompt_key="group-prompt") + agent.bind_prompt_store(store) + + with pytest.raises(NotImplementedError, match="does not implement group"): + agent.group([_make_artifact()]) + store.close() + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgentFromFile +# --------------------------------------------------------------------------- + + +class TestSynixLLMAgentFromFile: + def test_from_file_seeds_prompt_store(self, tmp_path): + from synix.server.prompt_store import PromptStore + + # Write instructions file + instructions_file = tmp_path / "instructions.txt" + instructions_file.write_text("You are a specialized agent.") + + store = PromptStore(tmp_path / "prompts.db") + agent = SynixLLMAgent.from_file( + name="file-agent", + prompt_key="file-key", + instructions_path=instructions_file, + prompt_store=store, + ) + + assert agent.agent_id == "file-agent" + assert agent.instructions == "You are a specialized agent." + # Verify it was actually stored + assert store.get("file-key") == "You are a specialized agent." + store.close() + + def test_from_file_with_extra_kwargs(self, tmp_path): + from synix.server.prompt_store import PromptStore + + instructions_file = tmp_path / "instr.txt" + instructions_file.write_text("Do things.") + + store = PromptStore(tmp_path / "prompts.db") + agent = SynixLLMAgent.from_file( + name="custom", + prompt_key="custom-key", + instructions_path=instructions_file, + prompt_store=store, + llm_config={"model": "gpt-4"}, + description="Custom agent", + ) + + assert agent.llm_config == {"model": "gpt-4"} + assert agent.description == "Custom agent" + store.close() + + +# --------------------------------------------------------------------------- +# TestFingerprintValue +# --------------------------------------------------------------------------- + + +class TestFingerprintValue: + def test_deterministic(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("key", "instructions") + agent = SynixLLMAgent(name="agent", prompt_key="key") + agent.bind_prompt_store(store) + + fp1 = agent.fingerprint_value() + fp2 = agent.fingerprint_value() + assert fp1 == fp2 + store.close() + + def test_changes_with_llm_config(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("key", "instructions") + + agent1 = SynixLLMAgent(name="agent", prompt_key="key") + agent1.bind_prompt_store(store) + + agent2 = SynixLLMAgent( + name="agent", prompt_key="key", llm_config={"model": "gpt-4"} + ) + agent2.bind_prompt_store(store) + + assert agent1.fingerprint_value() != agent2.fingerprint_value() + store.close() + + def test_changes_with_prompt_content(self, tmp_path): + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("key", "version A") + agent = SynixLLMAgent(name="agent", prompt_key="key") + agent.bind_prompt_store(store) + fp_a = agent.fingerprint_value() + + store.put("key", "version B") + fp_b = agent.fingerprint_value() + + assert fp_a != fp_b + store.close() + + def test_without_store_still_produces_fingerprint(self): + """Without a bound store, content_hash is empty but fingerprint still works.""" + agent = SynixLLMAgent(name="agent", prompt_key="key") + fp = agent.fingerprint_value() + assert isinstance(fp, str) + assert len(fp) > 0 + + def test_same_config_different_names_same_fingerprint(self, tmp_path): + """Fingerprint is based on content/config, not agent name.""" + from synix.server.prompt_store import PromptStore + + store = PromptStore(tmp_path / "test.db") + store.put("shared-key", "same instructions") + + agent1 = SynixLLMAgent(name="agent-1", prompt_key="shared-key") + agent1.bind_prompt_store(store) + + agent2 = SynixLLMAgent(name="agent-2", prompt_key="shared-key") + agent2.bind_prompt_store(store) + + assert agent1.fingerprint_value() == agent2.fingerprint_value() + store.close() From 8df17606cdc3c2911a37462b8bcffdd9742a5055 Mon Sep 17 00:00:00 2001 From: Mark Lubin Date: Mon, 6 Apr 2026 19:49:15 -0700 Subject: [PATCH 2/6] Fix review: fingerprint raises without store, load_agents api_key_env, compat aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fingerprint_value() raises ValueError when prompt store not bound (was silently hashing empty string — cache safety issue) - load_agents() now passes api_key_env from config (was missing) - AgentRequest/AgentResult kept as deprecated aliases for backward compat --- src/synix/__init__.py | 2 +- src/synix/agents.py | 20 ++++++++++++++++---- src/synix/workspace.py | 2 ++ tests/unit/test_agents.py | 9 ++++----- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/synix/__init__.py b/src/synix/__init__.py index 3f09ef3..ffde85c 100644 --- a/src/synix/__init__.py +++ b/src/synix/__init__.py @@ -2,7 +2,7 @@ __version__ = "0.22.2" -from synix.agents import Agent, Group, SynixLLMAgent # noqa: F401 +from synix.agents import Agent, AgentRequest, AgentResult, Group, SynixLLMAgent # noqa: F401 from synix.core.models import ( # noqa: F401 Artifact, FlatFile, diff --git a/src/synix/agents.py b/src/synix/agents.py index f9040a2..e09f5f9 100644 --- a/src/synix/agents.py +++ b/src/synix/agents.py @@ -100,12 +100,19 @@ def instructions(self) -> str: return content def fingerprint_value(self) -> str: - """Hash of prompt content (from store) + llm_config.""" + """Hash of prompt content (from store) + llm_config. + + Raises ValueError if prompt store is not bound — fingerprinting + requires a known prompt state for cache correctness. + """ from synix.build.fingerprint import compute_digest, fingerprint_value - content_hash = "" - if self._prompt_store is not None: - content_hash = self._prompt_store.content_hash(self.prompt_key) or "" + if self._prompt_store is None: + raise ValueError( + f"Agent {self.name!r} has no prompt store — " + "bind_prompt_store() before fingerprinting" + ) + content_hash = self._prompt_store.content_hash(self.prompt_key) or "" components = {"prompt_content": content_hash} if self.llm_config: @@ -189,3 +196,8 @@ def from_file( agent = cls(name=name, prompt_key=prompt_key, **kwargs) agent.bind_prompt_store(prompt_store) return agent + + +# Backward compatibility aliases (deprecated, will be removed) +AgentRequest = type("AgentRequest", (), {"__doc__": "Deprecated. Use typed Agent methods instead."}) +AgentResult = type("AgentResult", (), {"__doc__": "Deprecated. Use typed Agent methods instead."}) diff --git a/src/synix/workspace.py b/src/synix/workspace.py index 33f66d8..9291007 100644 --- a/src/synix/workspace.py +++ b/src/synix/workspace.py @@ -494,6 +494,8 @@ def load_agents(config_path: str | Path | None = None) -> dict: llm_config["model"] = ac.model if ac.base_url: llm_config["base_url"] = ac.base_url + if ac.api_key_env: + llm_config["api_key_env"] = ac.api_key_env if ac.temperature is not None: llm_config["temperature"] = ac.temperature llm_config["max_tokens"] = ac.max_tokens diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py index 062b8ff..c6eb180 100644 --- a/tests/unit/test_agents.py +++ b/tests/unit/test_agents.py @@ -513,12 +513,11 @@ def test_changes_with_prompt_content(self, tmp_path): assert fp_a != fp_b store.close() - def test_without_store_still_produces_fingerprint(self): - """Without a bound store, content_hash is empty but fingerprint still works.""" + def test_without_store_raises(self): + """Without a bound store, fingerprint_value() raises for cache safety.""" agent = SynixLLMAgent(name="agent", prompt_key="key") - fp = agent.fingerprint_value() - assert isinstance(fp, str) - assert len(fp) > 0 + with pytest.raises(ValueError, match="no prompt store"): + agent.fingerprint_value() def test_same_config_different_names_same_fingerprint(self, tmp_path): """Fingerprint is based on content/config, not agent name.""" From 7cb14ed384acb57e346396806affe3cf228eff2b Mon Sep 17 00:00:00 2001 From: Mark Lubin Date: Mon, 6 Apr 2026 19:53:13 -0700 Subject: [PATCH 3/6] =?UTF-8?q?Fix=20AgentRequest/AgentResult=20compat=20?= =?UTF-8?q?=E2=80=94=20keep=20as=20functional=20frozen=20dataclasses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/synix/agents.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/synix/agents.py b/src/synix/agents.py index e09f5f9..f0a7c34 100644 --- a/src/synix/agents.py +++ b/src/synix/agents.py @@ -198,6 +198,26 @@ def from_file( return agent -# Backward compatibility aliases (deprecated, will be removed) -AgentRequest = type("AgentRequest", (), {"__doc__": "Deprecated. Use typed Agent methods instead."}) -AgentResult = type("AgentResult", (), {"__doc__": "Deprecated. Use typed Agent methods instead."}) +# Backward compatibility — these were the v1 generic gateway types. +# Kept functional so existing code using AgentRequest(prompt=...) still works. +# Prefer using typed Agent methods (map/reduce/group/fold) instead. + + +@dataclass(frozen=True) +class AgentRequest: + """Deprecated: use typed Agent methods instead. + + Kept for backward compatibility with code that used the v1 + generic write(AgentRequest) gateway. + """ + + prompt: str + max_tokens: int | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AgentResult: + """Deprecated: use typed Agent methods instead.""" + + content: str From 11b5c79af9c1be1576c8f8074cf82f8e3209cdb2 Mon Sep 17 00:00:00 2001 From: Mark Lubin Date: Tue, 7 Apr 2026 13:50:15 -0700 Subject: [PATCH 4/6] =?UTF-8?q?Remove=20AgentRequest/AgentResult=20?= =?UTF-8?q?=E2=80=94=20never=20shipped,=20no=20backward=20compat=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/synix/__init__.py | 2 +- src/synix/agents.py | 24 ------------------------ 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/src/synix/__init__.py b/src/synix/__init__.py index ffde85c..3f09ef3 100644 --- a/src/synix/__init__.py +++ b/src/synix/__init__.py @@ -2,7 +2,7 @@ __version__ = "0.22.2" -from synix.agents import Agent, AgentRequest, AgentResult, Group, SynixLLMAgent # noqa: F401 +from synix.agents import Agent, Group, SynixLLMAgent # noqa: F401 from synix.core.models import ( # noqa: F401 Artifact, FlatFile, diff --git a/src/synix/agents.py b/src/synix/agents.py index f0a7c34..437544e 100644 --- a/src/synix/agents.py +++ b/src/synix/agents.py @@ -197,27 +197,3 @@ def from_file( agent.bind_prompt_store(prompt_store) return agent - -# Backward compatibility — these were the v1 generic gateway types. -# Kept functional so existing code using AgentRequest(prompt=...) still works. -# Prefer using typed Agent methods (map/reduce/group/fold) instead. - - -@dataclass(frozen=True) -class AgentRequest: - """Deprecated: use typed Agent methods instead. - - Kept for backward compatibility with code that used the v1 - generic write(AgentRequest) gateway. - """ - - prompt: str - max_tokens: int | None = None - metadata: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class AgentResult: - """Deprecated: use typed Agent methods instead.""" - - content: str From 0c9a70b7dfa4786d7e85fb340a9925291d8919a1 Mon Sep 17 00:00:00 2001 From: Mark Lubin Date: Tue, 7 Apr 2026 14:02:10 -0700 Subject: [PATCH 5/6] Compose transform prompt + agent instructions via task_prompt parameter Transform renders its prompt (task structure) and passes it as task_prompt to the agent. Agent uses task_prompt as user message, its own instructions as system message. Both compose: - Transform defines WHAT (e.g. "Summarize: {artifact}") - Agent defines HOW (e.g. "You are a concise writer, 200 words") Same agent works across different transforms. Same transform works with different agents. --- src/synix/agents.py | 91 ++++++++++++++-------------- src/synix/ext/fold_synthesis.py | 19 +++--- src/synix/ext/group_synthesis.py | 5 +- src/synix/ext/map_synthesis.py | 15 ++--- src/synix/ext/reduce_synthesis.py | 15 ++--- tests/e2e/test_agent_pipeline.py | 8 +-- tests/unit/test_agent_transforms.py | 85 +++++++++++++++----------- tests/unit/test_agents.py | 92 +++++++++++++++-------------- 8 files changed, 172 insertions(+), 158 deletions(-) diff --git a/src/synix/agents.py b/src/synix/agents.py index 437544e..e48c321 100644 --- a/src/synix/agents.py +++ b/src/synix/agents.py @@ -4,6 +4,11 @@ shapes (map, reduce, group, fold). Each agent has stable identity (agent_id) and a config snapshot fingerprint for cache invalidation. +The transform renders its prompt (task structure) and passes it as +task_prompt. The agent provides persona/semantic instructions and +executes the LLM call. Both compose: transform defines WHAT to do, +agent defines HOW to do it. + SynixLLMAgent is the built-in implementation backed by PromptStore for versioned instructions and LLMClient for execution. """ @@ -30,6 +35,10 @@ class Group: class Agent(Protocol): """Named execution agent for Synix pipeline operations. + Each method receives typed transform inputs plus a task_prompt + rendered by the transform. The agent composes its own instructions + (persona/semantics) with the task prompt and executes. + agent_id is stable identity (who this agent is). fingerprint_value() is config snapshot (how it behaves now). These have separate lifecycles. @@ -45,20 +54,32 @@ def fingerprint_value(self) -> str: Drives cache invalidation.""" ... - def map(self, artifact: Artifact) -> str: - """1:1 — process single artifact.""" + def map(self, artifact: Artifact, task_prompt: str) -> str: + """1:1 — process single artifact. + + task_prompt: rendered by the transform with {artifact}, {label}, etc. + """ ... - def reduce(self, artifacts: list[Artifact]) -> str: - """N:1 — combine artifacts into one.""" + def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str: + """N:1 — combine artifacts into one. + + task_prompt: rendered by the transform with {artifacts}, {count}, etc. + """ ... - def group(self, artifacts: list[Artifact]) -> list[Group]: - """N:M — assign artifacts to groups and synthesize each.""" + def group(self, artifacts: list[Artifact], task_prompt: str) -> list[Group]: + """N:M — assign artifacts to groups and synthesize each. + + task_prompt: rendered by the transform (may be used per-group). + """ ... - def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: - """Sequential — one fold step.""" + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int, task_prompt: str) -> str: + """Sequential — one fold step. + + task_prompt: rendered by the transform with {accumulated}, {artifact}, {step}, {total}, etc. + """ ... @@ -66,9 +87,10 @@ def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> s class SynixLLMAgent: """Built-in agent backed by PromptStore + LLMClient. - Instructions are loaded from PromptStore by prompt_key at call time, - so edits in the viewer are picked up automatically. fingerprint_value() - uses the prompt store's content hash, so cache invalidates on edit. + Instructions (persona/semantics) are loaded from PromptStore by + prompt_key at call time, so edits in the viewer are picked up + automatically. The transform's task_prompt becomes the user message, + agent instructions become the system message. """ name: str @@ -124,49 +146,23 @@ def bind_prompt_store(self, store) -> SynixLLMAgent: self._prompt_store = store return self - def map(self, artifact: Artifact) -> str: - from synix.ext._render import render_template - - rendered = render_template( - self.instructions, - artifact=artifact.content, - label=artifact.label, - artifact_type=artifact.artifact_type, - ) - return self._call(rendered) - - def reduce(self, artifacts: list[Artifact]) -> str: - from synix.ext._render import render_template + def map(self, artifact: Artifact, task_prompt: str) -> str: + return self._call(task_prompt) - joined = "\n---\n".join(f"### {a.label}\n{a.content}" for a in artifacts) - rendered = render_template( - self.instructions, - artifacts=joined, - count=str(len(artifacts)), - ) - return self._call(rendered) + def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str: + return self._call(task_prompt) - def group(self, artifacts: list[Artifact]) -> list[Group]: + def group(self, artifacts: list[Artifact], task_prompt: str) -> list[Group]: raise NotImplementedError( f"SynixLLMAgent {self.name!r} does not implement group(). " "See issue #127 for agent-driven grouping." ) - def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: - from synix.ext._render import render_template - - rendered = render_template( - self.instructions, - accumulated=accumulated, - artifact=artifact.content, - label=artifact.label, - step=str(step), - total=str(total), - ) - return self._call(rendered) + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int, task_prompt: str) -> str: + return self._call(task_prompt) - def _call(self, user_content: str) -> str: - """Execute an LLM call with instructions as system prompt.""" + def _call(self, task_prompt: str) -> str: + """Execute LLM call: instructions as system, task_prompt as user.""" from synix.build.llm_client import LLMClient from synix.core.config import LLMConfig @@ -175,7 +171,7 @@ def _call(self, user_content: str) -> str: response = client.complete( messages=[ {"role": "system", "content": self.instructions}, - {"role": "user", "content": user_content}, + {"role": "user", "content": task_prompt}, ], artifact_desc=f"agent:{self.name}", ) @@ -196,4 +192,3 @@ def from_file( agent = cls(name=name, prompt_key=prompt_key, **kwargs) agent.bind_prompt_store(prompt_store) return agent - diff --git a/src/synix/ext/fold_synthesis.py b/src/synix/ext/fold_synthesis.py index 64fcaa4..d9b12ba 100644 --- a/src/synix/ext/fold_synthesis.py +++ b/src/synix/ext/fold_synthesis.py @@ -177,17 +177,18 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac # --- Main fold loop (over new_inputs only) --- for step, inp in enumerate(new_inputs, start_step + 1): + rendered = render_template( + self.prompt, + accumulated=accumulated, + artifact=inp.content, + label=inp.label, + step=str(step), + total=str(total), + ) + if self.agent is not None: - accumulated = self.agent.fold(accumulated, inp, step, total) + accumulated = self.agent.fold(accumulated, inp, step, total, rendered) else: - rendered = render_template( - self.prompt, - accumulated=accumulated, - artifact=inp.content, - label=inp.label, - step=str(step), - total=str(total), - ) response = _logged_complete( client, ctx, diff --git a/src/synix/ext/group_synthesis.py b/src/synix/ext/group_synthesis.py index d1d64f9..6dc9dfb 100644 --- a/src/synix/ext/group_synthesis.py +++ b/src/synix/ext/group_synthesis.py @@ -174,9 +174,10 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac ctx = self.get_context(ctx) prompt_id = self._make_prompt_id() - # Agent path: agent owns grouping, rendering, and execution + # Agent path: render task prompt, agent owns grouping and execution if self.agent is not None: - groups = self.agent.group(inputs) + rendered = render_template(self.prompt, artifact_type=self.artifact_type) if self.prompt else "" + groups = self.agent.group(inputs, rendered) results: list[Artifact] = [] for g in groups: label = f"{self.label_prefix}-{g.key}" if self.label_prefix else g.key diff --git a/src/synix/ext/map_synthesis.py b/src/synix/ext/map_synthesis.py index a7ce2cc..f88d594 100644 --- a/src/synix/ext/map_synthesis.py +++ b/src/synix/ext/map_synthesis.py @@ -108,18 +108,19 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac inp = inputs[0] + rendered = render_template( + self.prompt, + artifact=inp.content, + label=inp.label, + artifact_type=inp.artifact_type, + ) + if self.agent is not None: - content = self.agent.map(inp) # agent owns rendering + execution + content = self.agent.map(inp, rendered) model_config = None agent_fingerprint = self.agent.fingerprint_value() agent_id_val = self.agent.agent_id else: - rendered = render_template( - self.prompt, - artifact=inp.content, - label=inp.label, - artifact_type=inp.artifact_type, - ) client = _get_llm_client(ctx) response = _logged_complete( client, diff --git a/src/synix/ext/reduce_synthesis.py b/src/synix/ext/reduce_synthesis.py index 67ffa74..41ec9b3 100644 --- a/src/synix/ext/reduce_synthesis.py +++ b/src/synix/ext/reduce_synthesis.py @@ -113,18 +113,19 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac # Sort inputs by artifact_id for deterministic prompt -> stable cassette key sorted_inputs = sorted(inputs, key=lambda a: a.artifact_id) + artifacts_text = "\n\n---\n\n".join(f"### {a.label}\n{a.content}" for a in sorted_inputs) + rendered = render_template( + self.prompt, + artifacts=artifacts_text, + count=str(len(sorted_inputs)), + ) + if self.agent is not None: - content = self.agent.reduce(sorted_inputs) # agent owns rendering + execution + content = self.agent.reduce(sorted_inputs, rendered) model_config = None agent_fingerprint = self.agent.fingerprint_value() agent_id_val = self.agent.agent_id else: - artifacts_text = "\n\n---\n\n".join(f"### {a.label}\n{a.content}" for a in sorted_inputs) - rendered = render_template( - self.prompt, - artifacts=artifacts_text, - count=str(len(inputs)), - ) client = _get_llm_client(ctx) response = _logged_complete( client, diff --git a/tests/e2e/test_agent_pipeline.py b/tests/e2e/test_agent_pipeline.py index a26ba00..244ab38 100644 --- a/tests/e2e/test_agent_pipeline.py +++ b/tests/e2e/test_agent_pipeline.py @@ -37,17 +37,17 @@ def agent_id(self) -> str: def fingerprint_value(self) -> str: return self._fingerprint - def map(self, artifact: Artifact) -> str: + def map(self, artifact: Artifact, task_prompt: str) -> str: return f"{self._prefix} processed {len(artifact.content)} chars" - def reduce(self, artifacts: list[Artifact]) -> str: + def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str: total_chars = sum(len(a.content) for a in artifacts) return f"{self._prefix} reduced {len(artifacts)} artifacts ({total_chars} chars)" - def group(self, artifacts: list[Artifact]) -> list[Group]: + def group(self, artifacts: list[Artifact], task_prompt: str) -> list[Group]: return [Group(key="all", artifacts=artifacts, content=f"{self._prefix} grouped")] - def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int, task_prompt: str) -> str: return f"{self._prefix} fold step {step}/{total}" diff --git a/tests/unit/test_agent_transforms.py b/tests/unit/test_agent_transforms.py index 12c861d..26992f2 100644 --- a/tests/unit/test_agent_transforms.py +++ b/tests/unit/test_agent_transforms.py @@ -20,10 +20,10 @@ def __init__(self, response: str = "agent output", fingerprint: str = "test-agen self._response = response self._fingerprint = fingerprint self._agent_id = agent_id - self.map_calls: list[Artifact] = [] - self.reduce_calls: list[list[Artifact]] = [] - self.group_calls: list[list[Artifact]] = [] - self.fold_calls: list[tuple[str, Artifact, int, int]] = [] + self.map_calls: list[tuple[Artifact, str]] = [] + self.reduce_calls: list[tuple[list[Artifact], str]] = [] + self.group_calls: list[tuple[list[Artifact], str]] = [] + self.fold_calls: list[tuple[str, Artifact, int, int, str]] = [] @property def agent_id(self) -> str: @@ -32,21 +32,21 @@ def agent_id(self) -> str: def fingerprint_value(self) -> str: return self._fingerprint - def map(self, artifact: Artifact) -> str: - self.map_calls.append(artifact) + def map(self, artifact: Artifact, task_prompt: str) -> str: + self.map_calls.append((artifact, task_prompt)) return self._response - def reduce(self, artifacts: list[Artifact]) -> str: - self.reduce_calls.append(artifacts) + def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str: + self.reduce_calls.append((artifacts, task_prompt)) return self._response - def group(self, artifacts: list[Artifact]) -> list[Group]: - self.group_calls.append(artifacts) + def group(self, artifacts: list[Artifact], task_prompt: str) -> list[Group]: + self.group_calls.append((artifacts, task_prompt)) # Return one group per artifact for testing return [Group(key=a.label, artifacts=[a], content=self._response) for a in artifacts] - def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: - self.fold_calls.append((accumulated, artifact, step, total)) + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int, task_prompt: str) -> str: + self.fold_calls.append((accumulated, artifact, step, total, task_prompt)) return self._response @@ -65,7 +65,7 @@ def _make_artifact(label: str, content: str = "content", **metadata) -> Artifact class TestMapWithAgent: - def test_agent_map_called_with_artifact(self): + def test_agent_map_called_with_artifact_and_task_prompt(self): agent = FakeAgent() t = MapSynthesis( "ws", @@ -77,8 +77,11 @@ def test_agent_map_called_with_artifact(self): t.execute([inp], {"llm_config": {}}) assert len(agent.map_calls) == 1 - assert agent.map_calls[0].label == "bio-alice" - assert agent.map_calls[0].content == "Alice is an engineer." + artifact, task_prompt = agent.map_calls[0] + assert artifact.label == "bio-alice" + assert artifact.content == "Alice is an engineer." + assert "Alice is an engineer." in task_prompt + assert task_prompt == "Analyze: Alice is an engineer." def test_artifact_has_agent_fingerprint(self): agent = FakeAgent(fingerprint="map-fp-123") @@ -128,7 +131,7 @@ def test_prompt_id_still_set(self): class TestReduceWithAgent: - def test_agent_reduce_called(self): + def test_agent_reduce_called_with_task_prompt(self): agent = FakeAgent() t = ReduceSynthesis( "team", @@ -141,11 +144,15 @@ def test_agent_reduce_called(self): results = t.execute(inputs, {"llm_config": {}}) assert len(agent.reduce_calls) == 1 + artifacts, task_prompt = agent.reduce_calls[0] # Reduce receives sorted artifacts - assert len(agent.reduce_calls[0]) == 3 + assert len(artifacts) == 3 assert results[0].agent_fingerprint == "test-agent-fp" assert results[0].agent_id == "test-agent" assert results[0].model_config is None + # task_prompt contains rendered artifacts text + assert "profile" in task_prompt + assert task_prompt.startswith("Analyze: ") def test_artifact_content_from_agent(self): agent = FakeAgent(response="reduced output") @@ -170,7 +177,7 @@ def test_artifact_has_agent_id(self): class TestGroupWithAgent: - def test_agent_group_called_with_all_inputs(self): + def test_agent_group_called_with_all_inputs_and_task_prompt(self): agent = FakeAgent() inputs = [ _make_artifact("ep-1", "content 1", team="alpha"), @@ -186,9 +193,12 @@ def test_agent_group_called_with_all_inputs(self): ) results = t.execute(inputs, {"llm_config": {}}) - # Agent.group() called once with all inputs + # Agent.group() called once with all inputs and task_prompt assert len(agent.group_calls) == 1 - assert len(agent.group_calls[0]) == 3 + artifacts, task_prompt = agent.group_calls[0] + assert len(artifacts) == 3 + # task_prompt is rendered from the prompt template + assert isinstance(task_prompt, str) # FakeAgent returns one group per artifact -> 3 results assert len(results) == 3 @@ -253,7 +263,7 @@ def test_group_metadata_contains_key_and_count(self): class TestFoldWithAgent: - def test_agent_called_per_step(self): + def test_agent_called_per_step_with_task_prompt(self): agent = FakeAgent(response="accumulated") inputs = [_make_artifact(f"ep-{i}", f"event {i}") for i in range(3)] t = FoldSynthesis( @@ -271,10 +281,12 @@ def test_agent_called_per_step(self): assert len(results) == 1 assert results[0].content == "accumulated" - # Verify step/total in each call - for i, (acc, art, step, total) in enumerate(agent.fold_calls): + # Verify step/total and task_prompt in each call + for i, (acc, art, step, total, task_prompt) in enumerate(agent.fold_calls): assert step == i + 1 assert total == 3 + assert "Current:" in task_prompt + assert "New:" in task_prompt def test_artifact_has_agent_fingerprint_and_id(self): agent = FakeAgent(fingerprint="fold-fp", agent_id="fold-agent-1") @@ -320,10 +332,11 @@ def test_fold_initial_passed_to_first_step(self): t.execute(inputs, {"llm_config": {}}) # First call should receive the initial value as accumulated - acc, _art, step, total = agent.fold_calls[0] + acc, _art, step, total, task_prompt = agent.fold_calls[0] assert acc == "INITIAL VALUE" assert step == 1 assert total == 1 + assert "INITIAL VALUE" in task_prompt # --------------------------------------------------------------------------- @@ -363,13 +376,13 @@ def agent_id(self): return "empty" def fingerprint_value(self): return "" - def map(self, artifact): + def map(self, artifact, task_prompt): return "x" - def reduce(self, artifacts): + def reduce(self, artifacts, task_prompt): return "x" - def group(self, artifacts): + def group(self, artifacts, task_prompt): return [] - def fold(self, accumulated, artifact, step, total): + def fold(self, accumulated, artifact, step, total, task_prompt): return "x" with pytest.raises(ValueError, match="empty fingerprint"): MapSynthesis("m", prompt="p", agent=EmptyFpAgent()) @@ -381,13 +394,13 @@ def agent_id(self): return "empty" def fingerprint_value(self): return "" - def map(self, artifact): + def map(self, artifact, task_prompt): return "x" - def reduce(self, artifacts): + def reduce(self, artifacts, task_prompt): return "x" - def group(self, artifacts): + def group(self, artifacts, task_prompt): return [] - def fold(self, accumulated, artifact, step, total): + def fold(self, accumulated, artifact, step, total, task_prompt): return "x" with pytest.raises(ValueError, match="empty fingerprint"): FoldSynthesis("f", prompt="p", label="out", agent=EmptyFpAgent()) @@ -608,16 +621,16 @@ def agent_id(self) -> str: def fingerprint_value(self) -> str: return "minimal-v1" - def map(self, artifact: Artifact) -> str: + def map(self, artifact: Artifact, task_prompt: str) -> str: return f"processed: {artifact.content[:20]}" - def reduce(self, artifacts: list[Artifact]) -> str: + def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str: return "reduced" - def group(self, artifacts: list[Artifact]) -> list[Group]: + def group(self, artifacts: list[Artifact], task_prompt: str) -> list[Group]: return [Group(key="all", artifacts=artifacts, content="grouped")] - def fold(self, accumulated: str, artifact: Artifact, step: int, total: int) -> str: + def fold(self, accumulated: str, artifact: Artifact, step: int, total: int, task_prompt: str) -> str: return f"{accumulated}+{artifact.label}" assert isinstance(MinimalAgent(), Agent) diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py index c6eb180..f9ab0f2 100644 --- a/tests/unit/test_agents.py +++ b/tests/unit/test_agents.py @@ -24,16 +24,16 @@ def agent_id(self): def fingerprint_value(self): return "fp" - def map(self, artifact): + def map(self, artifact, task_prompt): return "mapped" - def reduce(self, artifacts): + def reduce(self, artifacts, task_prompt): return "reduced" - def group(self, artifacts): + def group(self, artifacts, task_prompt): return [Group(key="g", artifacts=artifacts, content="grouped")] - def fold(self, accumulated, artifact, step, total): + def fold(self, accumulated, artifact, step, total, task_prompt): return "folded" @@ -74,10 +74,10 @@ def test_fake_agent_methods(self): art = _make_artifact() assert agent.agent_id == "fake" assert agent.fingerprint_value() == "fp" - assert agent.map(art) == "mapped" - assert agent.reduce([art]) == "reduced" - assert agent.fold("acc", art, 1, 5) == "folded" - groups = agent.group([art]) + assert agent.map(art, "task") == "mapped" + assert agent.reduce([art], "task") == "reduced" + assert agent.fold("acc", art, 1, 5, "task") == "folded" + groups = agent.group([art], "task") assert len(groups) == 1 assert groups[0].key == "g" @@ -90,13 +90,13 @@ def agent_id(self): def fingerprint_value(self): return "x" - def reduce(self, artifacts): + def reduce(self, artifacts, task_prompt): return "" - def group(self, artifacts): + def group(self, artifacts, task_prompt): return [] - def fold(self, accumulated, artifact, step, total): + def fold(self, accumulated, artifact, step, total, task_prompt): return "" assert not isinstance(NoMap(), Agent) @@ -107,16 +107,16 @@ class NoFingerprint: def agent_id(self): return "x" - def map(self, artifact): + def map(self, artifact, task_prompt): return "" - def reduce(self, artifacts): + def reduce(self, artifacts, task_prompt): return "" - def group(self, artifacts): + def group(self, artifacts, task_prompt): return [] - def fold(self, accumulated, artifact, step, total): + def fold(self, accumulated, artifact, step, total, task_prompt): return "" assert not isinstance(NoFingerprint(), Agent) @@ -130,13 +130,13 @@ def agent_id(self): def fingerprint_value(self): return "x" - def map(self, artifact): + def map(self, artifact, task_prompt): return "" - def group(self, artifacts): + def group(self, artifacts, task_prompt): return [] - def fold(self, accumulated, artifact, step, total): + def fold(self, accumulated, artifact, step, total, task_prompt): return "" assert not isinstance(NoReduce(), Agent) @@ -150,13 +150,13 @@ def agent_id(self): def fingerprint_value(self): return "x" - def map(self, artifact): + def map(self, artifact, task_prompt): return "" - def reduce(self, artifacts): + def reduce(self, artifacts, task_prompt): return "" - def fold(self, accumulated, artifact, step, total): + def fold(self, accumulated, artifact, step, total, task_prompt): return "" assert not isinstance(NoGroup(), Agent) @@ -170,13 +170,13 @@ def agent_id(self): def fingerprint_value(self): return "x" - def map(self, artifact): + def map(self, artifact, task_prompt): return "" - def reduce(self, artifacts): + def reduce(self, artifacts, task_prompt): return "" - def group(self, artifacts): + def group(self, artifacts, task_prompt): return [] assert not isinstance(NoFold(), Agent) @@ -291,7 +291,7 @@ def test_map_renders_and_calls_llm(self, tmp_path): from synix.server.prompt_store import PromptStore store = PromptStore(tmp_path / "test.db") - store.put("map-prompt", "Summarize: {artifact}") + store.put("map-prompt", "You are a summarizer.") agent = SynixLLMAgent(name="mapper", prompt_key="map-prompt") agent.bind_prompt_store(store) @@ -302,7 +302,7 @@ def test_map_renders_and_calls_llm(self, tmp_path): instance = MockClient.return_value instance.complete.return_value = mock_response - result = agent.map(_make_artifact(content="raw text")) + result = agent.map(_make_artifact(content="raw text"), task_prompt="Summarize: raw text") assert result == "Summary output" # Verify LLM was called with system + user messages @@ -311,7 +311,8 @@ def test_map_renders_and_calls_llm(self, tmp_path): assert len(messages) == 2 assert messages[0]["role"] == "system" assert messages[1]["role"] == "user" - assert "raw text" in messages[1]["content"] + # task_prompt is the user message now + assert "Summarize: raw text" in messages[1]["content"] store.close() @@ -321,11 +322,11 @@ def test_map_renders_and_calls_llm(self, tmp_path): class TestSynixLLMAgentReduce: - def test_reduce_joins_artifacts_and_calls_llm(self, tmp_path): + def test_reduce_passes_task_prompt_to_llm(self, tmp_path): from synix.server.prompt_store import PromptStore store = PromptStore(tmp_path / "test.db") - store.put("reduce-prompt", "Combine these {count} items:\n{artifacts}") + store.put("reduce-prompt", "You are a reducer agent.") agent = SynixLLMAgent(name="reducer", prompt_key="reduce-prompt") agent.bind_prompt_store(store) @@ -341,15 +342,17 @@ def test_reduce_joins_artifacts_and_calls_llm(self, tmp_path): instance = MockClient.return_value instance.complete.return_value = mock_response - result = agent.reduce(arts) + task_prompt = "Combine these 2 items:\nfirst\nsecond" + result = agent.reduce(arts, task_prompt) assert result == "Combined output" call_args = instance.complete.call_args messages = call_args.kwargs.get("messages") or call_args[1].get("messages") or call_args[0][0] - user_content = messages[1]["content"] - assert "first" in user_content - assert "second" in user_content - assert "2" in user_content # count + # System message is agent instructions, user message is task_prompt + assert messages[0]["role"] == "system" + assert messages[0]["content"] == "You are a reducer agent." + assert messages[1]["role"] == "user" + assert messages[1]["content"] == task_prompt store.close() @@ -359,14 +362,11 @@ def test_reduce_joins_artifacts_and_calls_llm(self, tmp_path): class TestSynixLLMAgentFold: - def test_fold_renders_with_accumulated_and_step(self, tmp_path): + def test_fold_passes_task_prompt_to_llm(self, tmp_path): from synix.server.prompt_store import PromptStore store = PromptStore(tmp_path / "test.db") - store.put( - "fold-prompt", - "Step {step}/{total}. So far: {accumulated}. New: {artifact}", - ) + store.put("fold-prompt", "You are a fold agent.") agent = SynixLLMAgent(name="folder", prompt_key="fold-prompt") agent.bind_prompt_store(store) @@ -377,21 +377,23 @@ def test_fold_renders_with_accumulated_and_step(self, tmp_path): instance = MockClient.return_value instance.complete.return_value = mock_response + task_prompt = "Step 3/10. So far: previous state. New: new item" result = agent.fold( accumulated="previous state", artifact=_make_artifact(content="new item"), step=3, total=10, + task_prompt=task_prompt, ) assert result == "Folded result" call_args = instance.complete.call_args messages = call_args.kwargs.get("messages") or call_args[1].get("messages") or call_args[0][0] - user_content = messages[1]["content"] - assert "3" in user_content - assert "10" in user_content - assert "previous state" in user_content - assert "new item" in user_content + # System message is agent instructions, user message is the task_prompt + assert messages[0]["role"] == "system" + assert messages[0]["content"] == "You are a fold agent." + assert messages[1]["role"] == "user" + assert messages[1]["content"] == task_prompt store.close() @@ -410,7 +412,7 @@ def test_group_raises_not_implemented(self, tmp_path): agent.bind_prompt_store(store) with pytest.raises(NotImplementedError, match="does not implement group"): - agent.group([_make_artifact()]) + agent.group([_make_artifact()], "Group these artifacts") store.close() From 6aaf8880a0c924c18d21697a7a31dccbbc6e2978 Mon Sep 17 00:00:00 2001 From: Mark Lubin Date: Tue, 7 Apr 2026 14:11:20 -0700 Subject: [PATCH 6/6] Add verbose logging at agent/non-agent branch in all transforms --- src/synix/ext/fold_synthesis.py | 4 ++++ src/synix/ext/group_synthesis.py | 4 ++++ src/synix/ext/map_synthesis.py | 5 +++++ src/synix/ext/reduce_synthesis.py | 5 +++++ 4 files changed, 18 insertions(+) diff --git a/src/synix/ext/fold_synthesis.py b/src/synix/ext/fold_synthesis.py index d9b12ba..32698ee 100644 --- a/src/synix/ext/fold_synthesis.py +++ b/src/synix/ext/fold_synthesis.py @@ -187,6 +187,10 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac ) if self.agent is not None: + logger.info( + "FoldSynthesis %r: agent %r fold step %d/%d (input: %s)", + self.name, self.agent.agent_id, step, total, inp.label, + ) accumulated = self.agent.fold(accumulated, inp, step, total, rendered) else: response = _logged_complete( diff --git a/src/synix/ext/group_synthesis.py b/src/synix/ext/group_synthesis.py index 6dc9dfb..04222e6 100644 --- a/src/synix/ext/group_synthesis.py +++ b/src/synix/ext/group_synthesis.py @@ -176,6 +176,10 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac # Agent path: render task prompt, agent owns grouping and execution if self.agent is not None: + logger.info( + "GroupSynthesis %r: agent %r grouping %d artifacts", + self.name, self.agent.agent_id, len(inputs), + ) rendered = render_template(self.prompt, artifact_type=self.artifact_type) if self.prompt else "" groups = self.agent.group(inputs, rendered) results: list[Artifact] = [] diff --git a/src/synix/ext/map_synthesis.py b/src/synix/ext/map_synthesis.py index f88d594..5fd30cd 100644 --- a/src/synix/ext/map_synthesis.py +++ b/src/synix/ext/map_synthesis.py @@ -116,11 +116,16 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac ) if self.agent is not None: + logger.info( + "MapSynthesis %r: agent %r executing map on %s", + self.name, self.agent.agent_id, inp.label, + ) content = self.agent.map(inp, rendered) model_config = None agent_fingerprint = self.agent.fingerprint_value() agent_id_val = self.agent.agent_id else: + logger.debug("MapSynthesis %r: built-in LLM path for %s", self.name, inp.label) client = _get_llm_client(ctx) response = _logged_complete( client, diff --git a/src/synix/ext/reduce_synthesis.py b/src/synix/ext/reduce_synthesis.py index 41ec9b3..75afb00 100644 --- a/src/synix/ext/reduce_synthesis.py +++ b/src/synix/ext/reduce_synthesis.py @@ -121,11 +121,16 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac ) if self.agent is not None: + logger.info( + "ReduceSynthesis %r: agent %r reducing %d artifacts", + self.name, self.agent.agent_id, len(sorted_inputs), + ) content = self.agent.reduce(sorted_inputs, rendered) model_config = None agent_fingerprint = self.agent.fingerprint_value() agent_id_val = self.agent.agent_id else: + logger.debug("ReduceSynthesis %r: built-in LLM path (%d artifacts)", self.name, len(sorted_inputs)) client = _get_llm_client(ctx) response = _logged_complete( client,