Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Agents

Agents are named execution units for Synix pipeline transforms. They replace anonymous LLM calls with identifiable, reusable personas that compose with transform task prompts.

## Concepts

**Transform prompt** defines the task structure — WHAT to do:
```python
"Infer this person's work style from their background.\n\n{artifact}"
```

**Agent instructions** define the persona — HOW to do it:
```
You are a concise analytical writer. Focus on patterns, dynamics,
and actionable insights. Write in 2-4 sentences.
```

Both compose: the transform renders its prompt as the user message, the agent provides its instructions as the system message.

## The Agent Protocol

```python
from synix.agents import Agent

class Agent(Protocol):
@property
def agent_id(self) -> str:
"""Stable identity — who this agent is."""

def fingerprint_value(self) -> str:
"""Config snapshot — drives cache invalidation."""

def map(self, artifact: Artifact, task_prompt: str) -> str:
"""1:1 transform."""

def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str:
"""N:1 transform."""

def group(self, artifacts: list[Artifact], task_prompt: str) -> list[Group]:
"""N:M transform."""

def fold(self, accumulated: str, artifact: Artifact, step: int, total: int, task_prompt: str) -> str:
"""Sequential fold transform."""
```

Each method matches a pipeline transform shape. The agent receives typed inputs plus the rendered task prompt from the transform.

### Identity vs Fingerprint

- **`agent_id`** — stable identity ("analyst", "reporter"). Changes only when the agent is replaced. Recorded in artifact provenance for lineage.
- **`fingerprint_value()`** — config snapshot hash. Changes when instructions, model, or temperature change. Drives cache invalidation. Different lifecycle from `agent_id`.

## SynixLLMAgent

The built-in implementation backed by PromptStore + LLMClient:

```python
from synix.agents import SynixLLMAgent

analyst = SynixLLMAgent(
name="analyst",
prompt_key="analyst", # key in PromptStore
llm_config={
"provider": "anthropic",
"model": "claude-haiku-4-5-20251001",
},
description="Concise analytical writer",
)
analyst.bind_prompt_store(store) # inject PromptStore for instructions
```

Instructions are loaded from the PromptStore at call time — edits in the viewer are picked up automatically. The fingerprint uses the prompt store's content hash, so cache invalidates on edit.

## Using Agents in Pipelines

```python
from synix.agents import SynixLLMAgent
from synix.transforms import MapSynthesis, ReduceSynthesis, FoldSynthesis

analyst = SynixLLMAgent(name="analyst", prompt_key="analyst")
reporter = SynixLLMAgent(name="reporter", prompt_key="reporter")

# Same agent, different tasks
work_styles = MapSynthesis(
"work_styles",
depends_on=[bios],
prompt="Infer this person's work style:\n\n{artifact}",
agent=analyst,
)

team_dynamics = ReduceSynthesis(
"team_dynamics",
depends_on=[work_styles],
prompt="Analyze team dynamics from these profiles:\n\n{artifacts}",
agent=analyst,
)

# Different agent, different task
final_report = FoldSynthesis(
"final_report",
depends_on=[team_dynamics, brief],
prompt="Update the report:\n\nDraft:\n{accumulated}\n\nNew:\n{artifact}",
agent=reporter,
)
```

The `prompt=` defines the task template (rendered by the transform with shape-specific placeholders). The `agent=` defines who executes it. Both are required when using agents.

## Workspace Configuration

Define agents in `synix.toml`:

```toml
[agents.analyst]
prompt_key = "analyst"
instructions_file = "prompts/analyst.txt"
description = "Concise analytical writer"

[agents.reporter]
prompt_key = "reporter"
instructions_file = "prompts/reporter.txt"
provider = "openai-compatible"
model = "Qwen/Qwen3.5-2B"
base_url = "http://localhost:8100/v1"
```

Load in pipeline.py:

```python
from synix.workspace import load_agents

agents = load_agents()
analyst = agents["analyst"]
reporter = agents["reporter"]
```

`load_agents()` reads `synix.toml`, creates `SynixLLMAgent` instances, and binds the workspace's PromptStore.

## Artifact Provenance

When an agent executes a transform step, the output artifact records:

- **`agent_id`** — which agent produced this ("analyst")
- **`agent_fingerprint`** — the agent's config snapshot hash at build time
- **`prompt_id`** — the transform's prompt template hash (separate from agent)

This enables lineage tracking: "this artifact was produced by the analyst agent (v3a8f) using the work-style task template (v1b2c)."

## Custom Agent Implementations

Any object satisfying the `Agent` protocol works:

```python
class MyCustomAgent:
@property
def agent_id(self):
return "my-agent"

def fingerprint_value(self):
return "v1-stable"

def map(self, artifact, task_prompt):
# Call your own API, local model, or anything else
return my_api.generate(task_prompt)

def reduce(self, artifacts, task_prompt):
return my_api.generate(task_prompt)

def group(self, artifacts, task_prompt):
# Return list[Group] with key, artifacts, content
...

def fold(self, accumulated, artifact, step, total, task_prompt):
return my_api.generate(task_prompt)
```

Pass it to any transform: `MapSynthesis("layer", prompt="...", agent=MyCustomAgent())`.
12 changes: 3 additions & 9 deletions src/synix/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,7 @@ def agent_id(self) -> str:
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"
)
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")
Expand All @@ -130,10 +128,7 @@ def fingerprint_value(self) -> str:
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"
)
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}
Expand All @@ -154,8 +149,7 @@ def reduce(self, artifacts: list[Artifact], task_prompt: str) -> str:

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."
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:
Expand Down
25 changes: 19 additions & 6 deletions src/synix/build/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,27 @@ def _complete_anthropic(
"""Complete using the Anthropic SDK with retry."""
import anthropic

# Extract system message — Anthropic requires system= parameter,
# not {"role": "system"} in messages list
system_text = None
api_messages = []
for msg in messages:
if msg.get("role") == "system":
system_text = msg["content"]
else:
api_messages.append(msg)

for attempt in range(2):
try:
response = self._client.messages.create(
model=self.config.model,
max_tokens=max_tokens,
temperature=temperature,
messages=messages,
)
kwargs = {
"model": self.config.model,
"max_tokens": max_tokens,
"temperature": temperature,
"messages": api_messages,
}
if system_text is not None:
kwargs["system"] = system_text
response = self._client.messages.create(**kwargs)
return LLMResponse(
content=response.content[0].text,
model=response.model if hasattr(response, "model") else self.config.model,
Expand Down
6 changes: 5 additions & 1 deletion src/synix/ext/fold_synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,11 @@ 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,
self.name,
self.agent.agent_id,
step,
total,
inp.label,
)
accumulated = self.agent.fold(accumulated, inp, step, total, rendered)
else:
Expand Down
28 changes: 16 additions & 12 deletions src/synix/ext/group_synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,25 +178,29 @@ def execute(self, inputs: list[Artifact], ctx: TransformContext) -> list[Artifac
if self.agent is not None:
logger.info(
"GroupSynthesis %r: agent %r grouping %d artifacts",
self.name, self.agent.agent_id, len(inputs),
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,
))
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
Expand Down
4 changes: 3 additions & 1 deletion src/synix/ext/map_synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ 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,
self.name,
self.agent.agent_id,
inp.label,
)
content = self.agent.map(inp, rendered)
model_config = None
Expand Down
4 changes: 3 additions & 1 deletion src/synix/ext/reduce_synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ 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),
self.name,
self.agent.agent_id,
len(sorted_inputs),
)
content = self.agent.reduce(sorted_inputs, rendered)
model_config = None
Expand Down
Loading
Loading