framework-agents 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. framework_agents/__init__.py +345 -0
  2. framework_agents/a2a_client.py +360 -0
  3. framework_agents/a2a_server.py +495 -0
  4. framework_agents/agent.py +920 -0
  5. framework_agents/checkpoint.py +231 -0
  6. framework_agents/config.py +200 -0
  7. framework_agents/conversation.py +249 -0
  8. framework_agents/errors.py +47 -0
  9. framework_agents/evals.py +127 -0
  10. framework_agents/mcp_client.py +477 -0
  11. framework_agents/memory.py +103 -0
  12. framework_agents/memory_compaction.py +63 -0
  13. framework_agents/middleware.py +1302 -0
  14. framework_agents/models/__init__.py +100 -0
  15. framework_agents/models/anthropic.py +339 -0
  16. framework_agents/models/capabilities.py +45 -0
  17. framework_agents/models/ollama.py +47 -0
  18. framework_agents/models/openai_compatible.py +409 -0
  19. framework_agents/observability.py +84 -0
  20. framework_agents/ontology_review.py +321 -0
  21. framework_agents/orchestration.py +169 -0
  22. framework_agents/pricing.py +63 -0
  23. framework_agents/prompts.py +132 -0
  24. framework_agents/rag.py +123 -0
  25. framework_agents/remote_sparql.py +213 -0
  26. framework_agents/runtime.py +88 -0
  27. framework_agents/sandbox.py +327 -0
  28. framework_agents/semantic.py +1614 -0
  29. framework_agents/serve.py +436 -0
  30. framework_agents/sessions.py +249 -0
  31. framework_agents/skills.py +213 -0
  32. framework_agents/state.py +89 -0
  33. framework_agents/streaming.py +74 -0
  34. framework_agents/swarm.py +318 -0
  35. framework_agents/tools.py +114 -0
  36. framework_agents/tracing.py +131 -0
  37. framework_agents/types.py +262 -0
  38. framework_agents/workflows.py +130 -0
  39. framework_agents-0.1.0.dist-info/METADATA +118 -0
  40. framework_agents-0.1.0.dist-info/RECORD +41 -0
  41. framework_agents-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,114 @@
1
+ """Tools: pydantic-validated args. Parity: src/tools/index.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Awaitable, Callable, Optional, Type
8
+
9
+ from pydantic import BaseModel, ValidationError
10
+
11
+ from .errors import AgentError
12
+ from .state import AgentState
13
+
14
+
15
+ @dataclass
16
+ class ToolContext:
17
+ """Arg #2 for tools that need state (agent name / agent state / invocation
18
+ state). The agent loop wires its AgentState and the LIVE per-run
19
+ invocation_state (by reference) at each dispatch, like the TS ``toolCtx``."""
20
+
21
+ agent_name: str = "agent"
22
+ # The agent's AgentState (dict-like via .get/.set) — defaults to a fresh
23
+ # AgentState for standalone/registry calls.
24
+ state: AgentState = field(default_factory=AgentState)
25
+ invocation_state: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ @dataclass
29
+ class ToolResult:
30
+ """Split raw output: model-visible content vs downstream-only artifact."""
31
+ content: Any
32
+ artifact: Any = None
33
+
34
+
35
+ async def _default_to_result(output: Any) -> ToolResult:
36
+ return ToolResult(content=output if isinstance(output, str) else _jsonish(output))
37
+
38
+
39
+ def _jsonish(value: Any) -> str:
40
+ import json
41
+
42
+ return json.dumps(value, default=str)
43
+
44
+
45
+ @dataclass
46
+ class Tool:
47
+ name: str
48
+ description: str
49
+ schema: Type[BaseModel] # pydantic model = the validated arg shape
50
+ execute: Callable[[Any, ToolContext | None], Awaitable[Any]]
51
+ to_result: Callable[[Any], Awaitable[ToolResult]] = _default_to_result
52
+
53
+
54
+ def define_tool(
55
+ *,
56
+ name: str,
57
+ description: str,
58
+ schema: Type[BaseModel],
59
+ execute: Callable[[Any, ToolContext | None], Awaitable[Any]],
60
+ to_result: Optional[Callable[[Any], Awaitable[ToolResult]]] = None,
61
+ ) -> Tool:
62
+ if not name:
63
+ raise ValueError("Tool must have a name")
64
+ return Tool(
65
+ name=name,
66
+ description=description,
67
+ schema=schema,
68
+ execute=execute,
69
+ to_result=to_result or _default_to_result,
70
+ )
71
+
72
+
73
+ def parameters_schema(tool: Tool) -> dict[str, Any] | None:
74
+ """JSON Schema for the wire — tools without it are advertised zero-arg.
75
+
76
+ Parity with the TS fix: the model must see real argument shapes.
77
+ """
78
+ try:
79
+ schema = tool.schema.model_json_schema()
80
+ return schema
81
+ except Exception: # pragma: no cover - exotic pydantic setups
82
+ return None
83
+
84
+
85
+ class ToolRegistry:
86
+ """Validating registry — single place tools are looked up + arg-checked."""
87
+
88
+ def __init__(self) -> None:
89
+ self._tools: dict[str, Tool] = {}
90
+
91
+ def register(self, tool: Tool) -> "ToolRegistry":
92
+ if tool.name in self._tools:
93
+ raise ValueError(f"Tool already registered: {tool.name}")
94
+ self._tools[tool.name] = tool
95
+ return self
96
+
97
+ def get(self, name: str) -> Optional[Tool]:
98
+ return self._tools.get(name)
99
+
100
+ def list(self) -> list[Tool]:
101
+ return list(self._tools.values())
102
+
103
+ async def call(self, name: str, raw_args: Any, ctx: ToolContext | None = None) -> Any:
104
+ """Validate args and execute. ``ctx`` is optional (None stays None —
105
+ callers may pass a ToolContext so registry calls don't force None on
106
+ tools that read ctx)."""
107
+ tool = self._tools.get(name)
108
+ if tool is None:
109
+ raise AgentError("UNKNOWN_TOOL", f"Unknown tool: {name}", retryable=False)
110
+ try:
111
+ args = tool.schema.model_validate(raw_args if isinstance(raw_args, dict) else {})
112
+ except ValidationError as err:
113
+ raise AgentError("TOOL_ARG_INVALID", f'Invalid args for tool "{name}": {err}', retryable=False) from err
114
+ return await tool.execute(tool.schema.model_validate(args.model_dump()), ctx)
@@ -0,0 +1,131 @@
1
+ """OTel-shaped tracing exporter. Parity: src/tracing/index.ts (span half).
2
+
3
+ Spans from AgentEvents, sunk to console/JSONL/memory. No OTel SDK — the
4
+ Span shape is transport-compatible, the sinks are local-first.
5
+
6
+ Pinned (TS post-fix):
7
+ - Spans are emitted DURATION-COMPLETE (started_at + ended_at both set at
8
+ emit time): no dangling open-span bookkeeping.
9
+ - Span ids are unique hex (secrets) — sequential per sink, no name-keyed map.
10
+ - run.done metrics + tool.result parent linkage. CAVEAT (parity with the TS
11
+ caveat): the parent is the LAST tool.call span emitted on THIS instance —
12
+ there is no run-root, so concurrent interleaved runs through one sink can
13
+ cross-link a tool.result to another run's tool.call. Fixing that (a run-root
14
+ span) is pending in the TS track too; keep it simple here.
15
+ - Coverage: ALL events (step/token/thinking/tool calls) — the TS bug lost
16
+ everything but tool calls / partial data; emit spans for every hook.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import secrets
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any, Optional, Protocol
27
+
28
+
29
+ @dataclass
30
+ class Span:
31
+ trace_id: str
32
+ span_id: str
33
+ name: str
34
+ started_at: str # ISO timestamp
35
+ ended_at: Optional[str] = None # ISO timestamp; duration-complete spans set both
36
+ parent_id: Optional[str] = None
37
+ attributes: dict[str, Any] = field(default_factory=dict)
38
+
39
+
40
+ class SpanSink(Protocol):
41
+ def emit(self, span: Span) -> None: ...
42
+
43
+
44
+ class ConsoleSpanSink:
45
+ def emit(self, span: Span) -> None:
46
+ print(f"[trace] {span.name}", json.dumps(span.attributes))
47
+
48
+
49
+ class JsonlSpanSink:
50
+ """Per-span JSON lines, appending; the parent dir is created (mkdir -p)."""
51
+
52
+ def __init__(self, path: str) -> None:
53
+ self.path = path
54
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
55
+
56
+ def emit(self, span: Span) -> None:
57
+ record = {
58
+ "traceId": span.trace_id,
59
+ "spanId": span.span_id,
60
+ "parentId": span.parent_id,
61
+ "name": span.name,
62
+ "startedAt": span.started_at,
63
+ "endedAt": span.ended_at,
64
+ "attributes": span.attributes,
65
+ }
66
+ with open(self.path, "a", encoding="utf-8") as handle:
67
+ handle.write(json.dumps(record) + "\n")
68
+
69
+
70
+ class MemorySpanSink:
71
+ def __init__(self) -> None:
72
+ self.spans: list[Span] = []
73
+
74
+ def emit(self, span: Span) -> None:
75
+ self.spans.append(span)
76
+
77
+
78
+ def _now_iso() -> str:
79
+ return datetime.now(timezone.utc).isoformat()
80
+
81
+
82
+ class TracingEvents:
83
+ """AgentEvents-shaped projections of run/step/tool lifecycle into spans."""
84
+
85
+ def __init__(self, sink: SpanSink, trace_id: Optional[str] = None) -> None:
86
+ self.sink = sink
87
+ self.trace_id = trace_id or secrets.token_hex(16)
88
+ # Sequential unique span ids only — no name-keyed map on this instance
89
+ # beyond the LAST tool.call (name-keyed maps leak across runs).
90
+ self._last_tool_call_span_id: Optional[str] = None
91
+
92
+ def _emit(self, name: str, attributes: dict[str, Any], parent_id: Optional[str] = None) -> Span:
93
+ now = _now_iso()
94
+ span = Span(
95
+ trace_id=self.trace_id,
96
+ span_id=secrets.token_hex(8), # 16 hex chars, unique per emission
97
+ parent_id=parent_id,
98
+ name=name,
99
+ started_at=now,
100
+ ended_at=now, # duration-complete at emit time
101
+ attributes=attributes,
102
+ )
103
+ self.sink.emit(span)
104
+ return span
105
+
106
+ def on_step(self, step: int) -> None:
107
+ self._emit("step.start", {"step": step})
108
+
109
+ def on_token(self, delta: str) -> None:
110
+ self._emit("model.token", {"delta": delta[:200]})
111
+
112
+ def on_thinking(self, delta: str) -> None:
113
+ self._emit("model.thinking", {"delta": delta[:200]})
114
+
115
+ def on_tool_call(self, name: str, args: Any) -> None:
116
+ span = self._emit("tool.call", {"tool": name, "args": args})
117
+ self._last_tool_call_span_id = span.span_id
118
+
119
+ def on_tool_result(self, name: str, output: Any) -> None:
120
+ # Parent = the most recent tool.call span on this instance (see the
121
+ # module caveat: no run root, so concurrent runs through one sink can
122
+ # cross-link — pending in the TS track as well).
123
+ self._emit("tool.result", {"tool": name, "output": output}, parent_id=self._last_tool_call_span_id)
124
+
125
+ def on_done(self, text: str, tool_calls: Any = None) -> None:
126
+ count = len(tool_calls) if tool_calls is not None else 0
127
+ self._emit("run.done", {"text": text[:500], "toolCalls": count})
128
+
129
+
130
+ def tracing_events(sink: SpanSink, trace_id: Optional[str] = None) -> TracingEvents:
131
+ return TracingEvents(sink=sink, trace_id=trace_id)
@@ -0,0 +1,262 @@
1
+ """Message + usage models. Parity: packages/typescript/src/types/*.
2
+
3
+ Pinned semantics (these are the FIXED ones — the python port encodes them
4
+ from day one, not the bugs the TS sweep found):
5
+
6
+ - TokenUsage.reasoning_tokens is a SUBSET of output_tokens on every supported
7
+ provider (OpenAI reasoning_tokens C completion_tokens; Together/Anthropic
8
+ think inside completion/output tokens). Costs/budgets NEVER add it on top.
9
+ - cost_usd is set only when a price is known — never guessed (never a fake $0).
10
+ - run()-created user turns carry no id (editMessage parity addresses by index).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import itertools
16
+ import json
17
+ from typing import Any, Literal
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
20
+ from pydantic import TypeAdapter
21
+
22
+ Role = Literal["system", "user", "assistant", "tool"]
23
+
24
+
25
+ class TokenUsage(BaseModel):
26
+ model_config = ConfigDict(populate_by_name=True)
27
+
28
+ input_tokens: int = Field(default=0, serialization_alias="inputTokens", validation_alias="inputTokens")
29
+ output_tokens: int = Field(default=0, serialization_alias="outputTokens", validation_alias="outputTokens")
30
+ reasoning_tokens: int | None = Field(
31
+ default=None,
32
+ description="SUBSET of output_tokens on every supported provider: report it, never add it",
33
+ serialization_alias="reasoningTokens",
34
+ validation_alias="reasoningTokens",
35
+ )
36
+ cost_usd: float | None = Field(
37
+ default=None,
38
+ description="USD cost when a price is known (see pricing). Never guessed.",
39
+ serialization_alias="costUsd",
40
+ validation_alias="costUsd",
41
+ )
42
+
43
+ def __add__(self, other: "TokenUsage") -> "TokenUsage":
44
+ return TokenUsage(
45
+ input_tokens=self.input_tokens + other.input_tokens,
46
+ output_tokens=self.output_tokens + other.output_tokens,
47
+ reasoning_tokens=(self.reasoning_tokens or 0) + (other.reasoning_tokens or 0),
48
+ )
49
+
50
+
51
+ class ToolCall(BaseModel):
52
+ id: str
53
+ name: str
54
+ args: Any = None
55
+
56
+
57
+ class ExecutedToolCall(BaseModel):
58
+ id: str
59
+ name: str
60
+ args: Any = None
61
+ output: Any = None
62
+
63
+
64
+ class TextBlock(BaseModel):
65
+ type: Literal["text"] = "text"
66
+ text: str
67
+
68
+
69
+ class ReasoningBlock(BaseModel):
70
+ type: Literal["reasoning"] = "reasoning"
71
+ reasoning: str
72
+
73
+
74
+ class ImageBlock(BaseModel):
75
+ type: Literal["image"] = "image"
76
+ url: str | None = None
77
+ data: str | None = None
78
+ mime_type: str | None = Field(default=None, serialization_alias="mimeType", validation_alias="mimeType")
79
+ file_id: str | None = Field(default=None, serialization_alias="fileId", validation_alias="fileId")
80
+
81
+
82
+ class AudioBlock(BaseModel):
83
+ type: Literal["audio"] = "audio"
84
+ url: str | None = None
85
+ data: str | None = None
86
+ mime_type: str | None = Field(default=None, serialization_alias="mimeType", validation_alias="mimeType")
87
+ file_id: str | None = Field(default=None, serialization_alias="fileId", validation_alias="fileId")
88
+
89
+
90
+ class FileBlock(BaseModel):
91
+ type: Literal["file"] = "file"
92
+ url: str | None = None
93
+ data: str | None = None
94
+ mime_type: str | None = Field(default=None, serialization_alias="mimeType", validation_alias="mimeType")
95
+ file_id: str | None = Field(default=None, serialization_alias="fileId", validation_alias="fileId")
96
+
97
+
98
+ class ToolCallBlock(BaseModel):
99
+ type: Literal["tool_call"] = "tool_call"
100
+ id: str
101
+ name: str
102
+ args: Any = None
103
+
104
+
105
+ class NonStandardBlock(BaseModel):
106
+ type: Literal["non_standard"] = "non_standard"
107
+ value: Any = None
108
+
109
+
110
+ ContentBlock = (
111
+ TextBlock
112
+ | ReasoningBlock
113
+ | ImageBlock
114
+ | AudioBlock
115
+ | FileBlock
116
+ | ToolCallBlock
117
+ | NonStandardBlock
118
+ )
119
+
120
+ _BLOCK_TYPES: dict[str, type[BaseModel]] = {
121
+ "text": TextBlock,
122
+ "reasoning": ReasoningBlock,
123
+ "image": ImageBlock,
124
+ "audio": AudioBlock,
125
+ "file": FileBlock,
126
+ "tool_call": ToolCallBlock,
127
+ "non_standard": NonStandardBlock,
128
+ }
129
+
130
+
131
+ def parse_content_block(raw: Any) -> BaseModel:
132
+ if not isinstance(raw, dict):
133
+ # A bare string (or any other shape) in a content list used to crash
134
+ # later text_of() work with AttributeError; fail loudly, naming it.
135
+ raise ValueError(
136
+ f"content list elements must be block dicts/models (got {type(raw).__name__})"
137
+ )
138
+ kind = raw.get("type")
139
+ cls = _BLOCK_TYPES.get(str(kind))
140
+ if cls is None:
141
+ raise ValueError(f"Unknown content block type: {kind!r}")
142
+ return cls.model_validate(raw)
143
+
144
+
145
+ # Parsed blocks are memoized per message INSTANCE (id-keyed): text_of /
146
+ # wire_text_of / providers re-parse the same content repeatedly per run.
147
+ # The entry is cleared whenever the model fields are (re)validated, and a
148
+ # new instance parses fresh (a fresh id never collides with a live entry).
149
+ _BLOCKS_CACHE: dict[int, list[BaseModel]] = {}
150
+
151
+
152
+ class ChatMessage(BaseModel):
153
+ model_config = ConfigDict(populate_by_name=True)
154
+
155
+ id: str | None = None
156
+ role: Role
157
+ content: str | list[Any] # string or parsed ContentBlock models (validated lazily)
158
+ name: str | None = None
159
+ tool_call_id: str | None = Field(default=None, serialization_alias="toolCallId", validation_alias="toolCallId")
160
+ tool_name: str | None = Field(default=None, serialization_alias="toolName", validation_alias="toolName")
161
+ pinned: bool | None = None
162
+
163
+ @model_validator(mode="after")
164
+ def _clear_block_cache(self) -> "ChatMessage":
165
+ _BLOCKS_CACHE.pop(id(self), None)
166
+ return self
167
+
168
+ def blocks(self) -> list[BaseModel]:
169
+ """Parsed content blocks (cached per instance; str content -> [])."""
170
+ if isinstance(self.content, str):
171
+ return []
172
+ cached = _BLOCKS_CACHE.get(id(self))
173
+ if cached is not None:
174
+ return list(cached)
175
+ parsed: list[BaseModel] = []
176
+ for block in self.content:
177
+ if isinstance(block, BaseModel):
178
+ parsed.append(block)
179
+ else:
180
+ parsed.append(parse_content_block(block))
181
+ _BLOCKS_CACHE[id(self)] = parsed
182
+ return list(parsed)
183
+
184
+
185
+ _message_seq = itertools.count(1)
186
+
187
+
188
+ def system(content: str | list[Any]) -> ChatMessage:
189
+ return ChatMessage(id=f"msg_{next(_message_seq)}", role="system", content=content)
190
+
191
+
192
+ def user(content: str | list[Any], id: str | None = None, name: str | None = None) -> ChatMessage:
193
+ msg_id = id or f"msg_{next(_message_seq)}"
194
+ return ChatMessage(id=msg_id, role="user", content=content, name=name)
195
+
196
+
197
+ def assistant(content: str | list[Any]) -> ChatMessage:
198
+ return ChatMessage(id=f"msg_{next(_message_seq)}", role="assistant", content=content)
199
+
200
+
201
+ def tool_result(tool_call_id: str, tool_name: str, content: str | list[Any]) -> ChatMessage:
202
+ return ChatMessage(
203
+ id=f"msg_{next(_message_seq)}",
204
+ role="tool",
205
+ content=content,
206
+ tool_call_id=tool_call_id,
207
+ tool_name=tool_name,
208
+ )
209
+
210
+
211
+ _CHAT_MESSAGE_LIST = TypeAdapter(list[ChatMessage])
212
+
213
+
214
+ def serialize_messages(messages: list[ChatMessage]) -> list[dict[str, Any]]:
215
+ """JSON-safe history for persistence."""
216
+ return [m.model_dump(by_alias=True, exclude_none=True) for m in messages]
217
+
218
+
219
+ def deserialize_messages(raw: list[dict[str, Any]]) -> list[ChatMessage]:
220
+ """Validated history restore. Raises on bad shape (never silently drops)."""
221
+ try:
222
+ return _CHAT_MESSAGE_LIST.validate_python(raw)
223
+ except ValidationError as err:
224
+ raise ValueError(f"Invalid message history: {err}") from err
225
+
226
+
227
+ def text_of(content: str | list[Any]) -> str:
228
+ """Plain-text view of any content shape."""
229
+ if isinstance(content, str):
230
+ return content
231
+ out: list[str] = []
232
+ for b in content if isinstance(content, list) else []:
233
+ block = b if isinstance(b, BaseModel) else parse_content_block(b)
234
+ if isinstance(block, TextBlock):
235
+ out.append(block.text)
236
+ elif isinstance(block, ReasoningBlock):
237
+ out.append(block.reasoning)
238
+ return "".join(out)
239
+
240
+
241
+ def wire_text_of(content: str | list[Any]) -> str:
242
+ """Text as re-sent to a provider: reasoning blocks are DROPPED
243
+ (a model's own past thinking must not come back as ordinary text)."""
244
+ if isinstance(content, str):
245
+ return content
246
+ out: list[str] = []
247
+ for b in content if isinstance(content, list) else []:
248
+ block = b if isinstance(b, BaseModel) else parse_content_block(b)
249
+ if isinstance(block, TextBlock):
250
+ out.append(block.text)
251
+ return "".join(out)
252
+
253
+
254
+ def has_images(content: str | list[Any]) -> bool:
255
+ return isinstance(content, list) and any(
256
+ (b if isinstance(b, dict) else b.model_dump()).get("type") == "image"
257
+ for b in content
258
+ )
259
+
260
+
261
+ def to_json_shallow(value: Any) -> str:
262
+ return json.dumps(value, default=str)
@@ -0,0 +1,130 @@
1
+ """DAG workflows. Parity: packages/typescript/src/workflows/index.ts.
2
+
3
+ Topological levels run in order; nodes within a level run concurrently.
4
+ Duplicate depends entries ("a", "a") are ONE edge — counting them twice
5
+ wedges indegree at 1 and misreports a legal graph as a cycle (pinned TS
6
+ regression). Abort exceptions always rethrow, never retry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Awaitable, Callable, Optional
14
+
15
+ from .errors import AgentError
16
+
17
+
18
+ @dataclass
19
+ class RetryPolicy:
20
+ attempts: int = 1
21
+ backoff_ms: float = 0
22
+
23
+
24
+ @dataclass
25
+ class WorkflowNode:
26
+ id: str
27
+ run: Callable[[dict[str, Any], Optional[dict[str, Any]]], Awaitable[Any]]
28
+ depends_on: list[str] = field(default_factory=list)
29
+ """Retry policy. Default: no retry (attempts=1). Abort always rethrows."""
30
+ retry: Optional[RetryPolicy] = None
31
+
32
+
33
+ @dataclass
34
+ class WorkflowOptions:
35
+ """Shared per-run data passed to every node (never sent to models by itself)."""
36
+ invocation_state: Optional[dict[str, Any]] = None
37
+
38
+
39
+ def _sort_topo(nodes: list[WorkflowNode]) -> list[list[WorkflowNode]]:
40
+ by_id: dict[str, WorkflowNode] = {}
41
+ for n in nodes:
42
+ if n.id in by_id:
43
+ raise AgentError("WORKFLOW_DUPLICATE_ID", "Workflow node ids must be unique", retryable=False)
44
+ by_id[n.id] = n
45
+ for n in nodes:
46
+ for dep in n.depends_on or []:
47
+ if dep not in by_id:
48
+ raise AgentError("WORKFLOW_UNKNOWN_DEP", f'Node "{n.id}" depends on unknown node "{dep}"', retryable=False)
49
+ if dep == n.id:
50
+ raise AgentError("WORKFLOW_CYCLE", f'Node "{n.id}" depends on itself', retryable=False)
51
+ # Deduplicate first: what matters for the indegree is the number of
52
+ # DISTINCT dependencies, not the raw list length.
53
+ indegree = {n.id: len(set(n.depends_on or [])) for n in nodes}
54
+ levels: list[list[WorkflowNode]] = []
55
+ ready = [n for n in nodes if indegree[n.id] == 0]
56
+ while ready:
57
+ levels.append(ready)
58
+ ready_ids = {n.id for n in ready}
59
+ deps: dict[str, set[str]] = {m.id: set(m.depends_on or []) for m in nodes}
60
+ nxt: list[WorkflowNode] = []
61
+ for done in nodes:
62
+ if done.id not in ready_ids:
63
+ continue
64
+ for m in nodes:
65
+ if done.id not in deps[m.id]:
66
+ continue
67
+ indegree[m.id] -= 1
68
+ if indegree[m.id] == 0:
69
+ nxt.append(m)
70
+ ready = nxt
71
+ if sum(len(level) for level in levels) != len(nodes):
72
+ raise AgentError("WORKFLOW_CYCLE", "Workflow has a dependency cycle", retryable=False)
73
+ return levels
74
+
75
+
76
+ def _policy_of(node: WorkflowNode) -> RetryPolicy:
77
+ retry = node.retry
78
+ if isinstance(retry, dict):
79
+ return RetryPolicy(**retry)
80
+ if retry is None:
81
+ return RetryPolicy()
82
+ return retry
83
+
84
+
85
+ async def _run_with_retry(
86
+ node: WorkflowNode,
87
+ ctx: dict[str, Any],
88
+ invocation_state: Optional[dict[str, Any]],
89
+ ) -> Any:
90
+ policy = _policy_of(node)
91
+ attempts = max(1, policy.attempts or 1)
92
+ backoff_ms = policy.backoff_ms or 0
93
+ last: BaseException | None = None
94
+ for i in range(attempts):
95
+ try:
96
+ return await node.run(ctx, invocation_state)
97
+ except asyncio.CancelledError:
98
+ raise
99
+ except AgentError as err:
100
+ if err.code == "ABORTED":
101
+ raise
102
+ last = err
103
+ except Exception as err: # noqa: BLE001 - node body errors retry per policy
104
+ last = err
105
+ if i < attempts - 1 and backoff_ms > 0:
106
+ await asyncio.sleep(backoff_ms / 1000)
107
+ assert last is not None # pragma: no cover - loop always sets it on fallthrough
108
+ raise last
109
+
110
+
111
+ async def run_workflow(
112
+ nodes: list[WorkflowNode],
113
+ opts: Optional[WorkflowOptions] = None,
114
+ ) -> dict[str, Any]:
115
+ """Execute a DAG. Returns id -> result. Errors (incl. aborts) propagate."""
116
+ invocation_state = opts.invocation_state if opts is not None else None
117
+ ctx: dict[str, Any] = {}
118
+ for level in _sort_topo(nodes):
119
+ values = await asyncio.gather(*(_run_with_retry(n, ctx, invocation_state) for n in level))
120
+ for node, value in zip(level, values):
121
+ ctx[node.id] = value
122
+ return ctx
123
+
124
+
125
+ __all__ = [
126
+ "RetryPolicy",
127
+ "WorkflowNode",
128
+ "WorkflowOptions",
129
+ "run_workflow",
130
+ ]