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..e48c321 100644 --- a/src/synix/agents.py +++ b/src/synix/agents.py @@ -1,62 +1,194 @@ -"""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. +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. """ 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. + + 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. - 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.""" + ... + + 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], task_prompt: str) -> str: + """N:1 — combine artifacts into one. + + task_prompt: rendered by the transform with {artifacts}, {count}, etc. + """ + ... - 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 group(self, artifacts: list[Artifact], task_prompt: str) -> list[Group]: + """N:M — assign artifacts to groups and synthesize each. - Examples of what an implementation may include: - model/version, instructions, tool set, endpoint revision, - decoding parameters, externally managed prompt version. + task_prompt: rendered by the transform (may be used per-group). """ ... + + 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. + """ + ... + + +@dataclass +class SynixLLMAgent: + """Built-in agent backed by PromptStore + LLMClient. + + 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 + 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. + + 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 + + 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: + 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, task_prompt: str) -> str: + return self._call(task_prompt) + + def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str: + return self._call(task_prompt) + + 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, task_prompt: str) -> str: + return self._call(task_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 + + config = LLMConfig.from_dict(self.llm_config or {}) + client = LLMClient(config) + response = client.complete( + messages=[ + {"role": "system", "content": self.instructions}, + {"role": "user", "content": task_prompt}, + ], + 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..32698ee 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) @@ -185,19 +187,11 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac ) 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 + 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( client, @@ -229,6 +223,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..04222e6 100644 --- a/src/synix/ext/group_synthesis.py +++ b/src/synix/ext/group_synthesis.py @@ -172,16 +172,42 @@ 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: 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] = [] + 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 +220,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 +244,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..5fd30cd 100644 --- a/src/synix/ext/map_synthesis.py +++ b/src/synix/ext/map_synthesis.py @@ -107,6 +107,7 @@ 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, @@ -115,21 +116,16 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac ) 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 + 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, @@ -140,6 +136,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 +156,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..75afb00 100644 --- a/src/synix/ext/reduce_synthesis.py +++ b/src/synix/ext/reduce_synthesis.py @@ -112,30 +112,25 @@ 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) + 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)), + count=str(len(sorted_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 + 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, @@ -146,6 +141,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 +154,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..9291007 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,88 @@ 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.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=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..244ab38 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, task_prompt: str) -> str: + return f"{self._prefix} processed {len(artifact.content)} chars" + + 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], 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, task_prompt: str) -> 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..26992f2 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[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]] = [] - 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, task_prompt: str) -> str: + self.map_calls.append((artifact, task_prompt)) + return self._response + + 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], 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, task_prompt: str) -> str: + self.fold_calls.append((accumulated, artifact, step, total, task_prompt)) + 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_and_task_prompt(self): agent = FakeAgent() t = MapSynthesis( "ws", @@ -55,13 +76,12 @@ 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 + 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") @@ -71,6 +91,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 +131,7 @@ def test_prompt_id_still_set(self): class TestReduceWithAgent: - def test_agent_write_called(self): + def test_agent_reduce_called_with_task_prompt(self): agent = FakeAgent() t = ReduceSynthesis( "team", @@ -115,13 +143,16 @@ 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 + artifacts, task_prompt = agent.reduce_calls[0] + # Reduce receives sorted artifacts + 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") @@ -131,6 +162,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 +177,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_and_task_prompt(self): agent = FakeAgent() inputs = [ _make_artifact("ep-1", "content 1", team="alpha"), @@ -154,19 +193,17 @@ 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" - - def test_artifacts_have_agent_fingerprint(self): - agent = FakeAgent(fingerprint="group-fp") + # Agent.group() called once with all inputs and task_prompt + assert len(agent.group_calls) == 1 + 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 + + 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 +214,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 @@ -186,7 +263,7 @@ def test_artifacts_have_agent_fingerprint(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( @@ -200,19 +277,19 @@ 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 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(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 +300,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 +319,25 @@ 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, task_prompt = agent.fold_calls[0] + assert acc == "INITIAL VALUE" + assert step == 1 + assert total == 1 + assert "INITIAL VALUE" in task_prompt + # --------------------------------------------------------------------------- # Prompt still required @@ -274,19 +371,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, task_prompt): + return "x" + def reduce(self, artifacts, task_prompt): + return "x" + def group(self, artifacts, task_prompt): + return [] + def fold(self, accumulated, artifact, step, total, task_prompt): + 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, task_prompt): + return "x" + def reduce(self, artifacts, task_prompt): + return "x" + def group(self, artifacts, task_prompt): + return [] + 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()) @@ -304,6 +419,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 +428,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 +437,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 +446,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 +537,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 +614,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, task_prompt: str) -> str: + return f"processed: {artifact.content[:20]}" + + def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str: + return "reduced" + + 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, task_prompt: str) -> str: + return f"{accumulated}+{artifact.label}" + assert isinstance(MinimalAgent(), Agent) agent = MinimalAgent() @@ -473,3 +641,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..f9ab0f2 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, task_prompt): + return "mapped" + def reduce(self, artifacts, task_prompt): + 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, task_prompt): + return [Group(key="g", artifacts=artifacts, content="grouped")] + + def fold(self, accumulated, artifact, step, total, task_prompt): + 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,470 @@ 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, "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" + + 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, task_prompt): + return "" + + def group(self, artifacts, task_prompt): + return [] - def test_fingerprint_value(self): - agent = FakeAgent(fingerprint="abc123") - assert agent.fingerprint_value() == "abc123" + def fold(self, accumulated, artifact, step, total, task_prompt): + return "" - def test_fingerprint_deterministic(self): - agent = FakeAgent(fingerprint="stable") - assert agent.fingerprint_value() == agent.fingerprint_value() + assert not isinstance(NoMap(), Agent) - def test_object_without_write_fails_isinstance(self): - class NotAnAgent: - def fingerprint_value(self) -> str: + def test_missing_fingerprint_fails_isinstance(self): + class NoFingerprint: + @property + def agent_id(self): return "x" - assert not isinstance(NotAnAgent(), Agent) + def map(self, artifact, task_prompt): + return "" + + def reduce(self, artifacts, task_prompt): + return "" + + def group(self, artifacts, task_prompt): + return [] + + def fold(self, accumulated, artifact, step, total, task_prompt): + 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, task_prompt): + return "" + + def group(self, artifacts, task_prompt): + return [] + + def fold(self, accumulated, artifact, step, total, task_prompt): + return "" + + assert not isinstance(NoReduce(), Agent) + + 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, task_prompt): + return "" + + def reduce(self, artifacts, task_prompt): + return "" + + def fold(self, accumulated, artifact, step, total, task_prompt): + return "" + + assert not isinstance(NoGroup(), Agent) + + def test_missing_fold_fails_isinstance(self): + class NoFold: + @property + def agent_id(self): + return "x" + + def fingerprint_value(self): + return "x" + + def map(self, artifact, task_prompt): + return "" + + def reduce(self, artifacts, task_prompt): + return "" + + def group(self, artifacts, task_prompt): + return [] + + assert not isinstance(NoFold(), Agent) + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgent +# --------------------------------------------------------------------------- + + +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 == "" + + 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 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 - def test_object_without_fingerprint_fails_isinstance(self): - class NotAnAgent: - def write(self, request): - return AgentResult(content="x") + 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" - assert not isinstance(NotAnAgent(), Agent) + store.put("prompt", "Version 2") + assert agent.instructions == "Version 2" + store.close() - 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() + 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) -class TestArtifactAgentFingerprint: - def test_artifact_has_agent_fingerprint_field(self): - from synix.core.models import Artifact + fp1 = agent.fingerprint_value() + store.put("prompt", "Updated instructions") + fp2 = agent.fingerprint_value() + assert fp1 != fp2 - 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 +# --------------------------------------------------------------------------- +# TestSynixLLMAgentMap +# --------------------------------------------------------------------------- - art = Artifact( - label="test", - artifact_type="test", - content="hello", - agent_fingerprint="agent-fp-xyz", + +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", "You are a summarizer.") + 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"), task_prompt="Summarize: 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" + # task_prompt is the user message now + assert "Summarize: raw text" in messages[1]["content"] + store.close() + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgentReduce +# --------------------------------------------------------------------------- + + +class TestSynixLLMAgentReduce: + 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", "You are a reducer agent.") + 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 + + 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] + # 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() + + +# --------------------------------------------------------------------------- +# TestSynixLLMAgentFold +# --------------------------------------------------------------------------- + + +class TestSynixLLMAgentFold: + 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", "You are a fold agent.") + 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 + + 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] + # 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() + + +# --------------------------------------------------------------------------- +# 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()], "Group these artifacts") + 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"} ) - assert art.agent_fingerprint == "agent-fp-xyz" + 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_raises(self): + """Without a bound store, fingerprint_value() raises for cache safety.""" + agent = SynixLLMAgent(name="agent", prompt_key="key") + 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 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()