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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- 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
|
+
]
|