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,321 @@
|
|
|
1
|
+
"""Ontology self-review + SHACL induction. Parity:
|
|
2
|
+
packages/typescript/src/semantic/ontology-review.ts.
|
|
3
|
+
|
|
4
|
+
The agent audits what its own ontology IS — not what it wishes it were —
|
|
5
|
+
directly from the store, and identifies what to add:
|
|
6
|
+
|
|
7
|
+
1. review_ontology(): inventory + gaps. Used-but-undeclared terms,
|
|
8
|
+
declared-but-unused terms, unlabeled terms, properties missing
|
|
9
|
+
rdfs:domain/range. "Where is X used?", "what's missing?" — one call.
|
|
10
|
+
2. propose_shapes(): SHACL INDUCTION from instance data — observe each
|
|
11
|
+
class's instances, emit candidate NodeShapes (min/maxCount, datatype,
|
|
12
|
+
nodeKind) that explain the observed data. The builder owns shapes: these
|
|
13
|
+
are PROPOSALS — adopt via adopt_shapes() (validates the existing closure
|
|
14
|
+
first) or the next migrate_t_box(shapes=...); never a silent adoption.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from .semantic import (
|
|
25
|
+
InMemoryGraphStore,
|
|
26
|
+
LABEL_PREDICATES,
|
|
27
|
+
OWL_VOCAB,
|
|
28
|
+
RDF_TYPE,
|
|
29
|
+
TBOX_PREDICATES,
|
|
30
|
+
NodeShape,
|
|
31
|
+
PropertyShape,
|
|
32
|
+
Quad,
|
|
33
|
+
is_literal,
|
|
34
|
+
strip_literal,
|
|
35
|
+
term_datatype,
|
|
36
|
+
)
|
|
37
|
+
from .tools import Tool, ToolContext, define_tool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class TermStat:
|
|
42
|
+
term: str
|
|
43
|
+
kind: str
|
|
44
|
+
#: Explicit ABox uses: instance-of counts for classes, fact counts for
|
|
45
|
+
#: properties.
|
|
46
|
+
uses: int = 0
|
|
47
|
+
#: TBox axioms mentioning the term (declarations, subClassOf, domain/range...).
|
|
48
|
+
axiom_count: int = 0
|
|
49
|
+
label: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class OntologyReview:
|
|
54
|
+
classes: list[TermStat] = field(default_factory=list)
|
|
55
|
+
properties: list[TermStat] = field(default_factory=list)
|
|
56
|
+
#: Used in data with zero TBox declarations — the "what to add" list.
|
|
57
|
+
undeclared: list[TermStat] = field(default_factory=list)
|
|
58
|
+
#: Declared but never used in any explicit fact.
|
|
59
|
+
unused: list[str] = field(default_factory=list)
|
|
60
|
+
#: Terms without rdfs:label / skos:prefLabel / altLabel.
|
|
61
|
+
unlabeled: list[str] = field(default_factory=list)
|
|
62
|
+
#: Properties missing rdfs:domain / rdfs:range axioms.
|
|
63
|
+
missing_domain: list[str] = field(default_factory=list)
|
|
64
|
+
missing_range: list[str] = field(default_factory=list)
|
|
65
|
+
#: Explicit quads vs closure size — how much the ontology explains.
|
|
66
|
+
explicit_quads: int = 0
|
|
67
|
+
closure_quads: int = 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_axiom_quad(q: Quad, predicates_in_use: set[str]) -> bool:
|
|
71
|
+
"""True when the quad is an ontology statement, not a fact."""
|
|
72
|
+
return q.predicate in TBOX_PREDICATES or (
|
|
73
|
+
q.predicate in RDF_TYPE and (q.subject in predicates_in_use or q.object in OWL_VOCAB)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def review_ontology(store: InMemoryGraphStore) -> OntologyReview:
|
|
78
|
+
"""Audit the store: inventory + gap list. Pure read — never mutates."""
|
|
79
|
+
closure = await store.match()
|
|
80
|
+
explicit = [q for q in closure if q.source != "entailment"]
|
|
81
|
+
predicates_in_use = {q.predicate for q in explicit}
|
|
82
|
+
|
|
83
|
+
labels: dict[str, str] = {}
|
|
84
|
+
for q in closure:
|
|
85
|
+
if q.predicate in LABEL_PREDICATES:
|
|
86
|
+
labels[q.subject] = strip_literal(q.object)
|
|
87
|
+
|
|
88
|
+
classes: dict[str, TermStat] = {}
|
|
89
|
+
properties: dict[str, TermStat] = {}
|
|
90
|
+
|
|
91
|
+
def class_(term: str) -> TermStat:
|
|
92
|
+
if term not in classes:
|
|
93
|
+
classes[term] = TermStat(term=term, kind="class", label=labels.get(term))
|
|
94
|
+
return classes[term]
|
|
95
|
+
|
|
96
|
+
def property_(term: str) -> TermStat:
|
|
97
|
+
if term not in properties:
|
|
98
|
+
properties[term] = TermStat(term=term, kind="property", label=labels.get(term))
|
|
99
|
+
return properties[term]
|
|
100
|
+
|
|
101
|
+
domains: dict[str, int] = {}
|
|
102
|
+
ranges: dict[str, int] = {}
|
|
103
|
+
for q in explicit:
|
|
104
|
+
axiom = _is_axiom_quad(q, predicates_in_use)
|
|
105
|
+
|
|
106
|
+
# rdf:type: axiom = term declaration (TBox); fact = instance typing (ABox).
|
|
107
|
+
if q.predicate in RDF_TYPE:
|
|
108
|
+
if axiom:
|
|
109
|
+
class_(q.subject).axiom_count += 1
|
|
110
|
+
else:
|
|
111
|
+
class_(q.object).uses += 1
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if q.predicate in TBOX_PREDICATES:
|
|
115
|
+
# TBox statements introduce/declare their subject term.
|
|
116
|
+
subject = class_(q.subject) if "subClassOf" in q.predicate else property_(q.subject)
|
|
117
|
+
subject.axiom_count += 1
|
|
118
|
+
# Their object is a class term (domain/range/subClassOf targets).
|
|
119
|
+
if "inverseOf" not in q.predicate:
|
|
120
|
+
class_(q.object).axiom_count += 1
|
|
121
|
+
if "domain" in q.predicate:
|
|
122
|
+
domains[q.subject] = domains.get(q.subject, 0) + 1
|
|
123
|
+
if "range" in q.predicate:
|
|
124
|
+
ranges[q.subject] = ranges.get(q.subject, 0) + 1
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# ABox fact: a property use with actual data behind it.
|
|
128
|
+
property_(q.predicate).uses += 1
|
|
129
|
+
|
|
130
|
+
all_terms = [*classes.values(), *properties.values()]
|
|
131
|
+
undeclared = sorted(
|
|
132
|
+
(t for t in all_terms if t.uses > 0 and t.axiom_count == 0),
|
|
133
|
+
key=lambda t: -t.uses,
|
|
134
|
+
)
|
|
135
|
+
unused = [t.term for t in all_terms if t.axiom_count > 0 and t.uses == 0]
|
|
136
|
+
unlabeled = [t.term for t in all_terms if not t.label]
|
|
137
|
+
known_props = [t for t in properties.values() if t.axiom_count > 0 or t.uses > 0]
|
|
138
|
+
missing_domain = [t.term for t in known_props if t.term not in domains]
|
|
139
|
+
missing_range = [t.term for t in known_props if t.term not in ranges]
|
|
140
|
+
|
|
141
|
+
return OntologyReview(
|
|
142
|
+
classes=sorted(classes.values(), key=lambda t: (-t.uses, t.term)),
|
|
143
|
+
properties=sorted(properties.values(), key=lambda t: (-t.uses, t.term)),
|
|
144
|
+
undeclared=undeclared,
|
|
145
|
+
unused=unused,
|
|
146
|
+
unlabeled=unlabeled,
|
|
147
|
+
missing_domain=missing_domain,
|
|
148
|
+
missing_range=missing_range,
|
|
149
|
+
explicit_quads=len(explicit),
|
|
150
|
+
closure_quads=len(closure),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# SHACL induction from instance data
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def propose_shapes(
|
|
160
|
+
store: InMemoryGraphStore,
|
|
161
|
+
min_instances: int = 2,
|
|
162
|
+
coverage: float = 0.5,
|
|
163
|
+
) -> list[NodeShape]:
|
|
164
|
+
"""Propose NodeShapes by observing explicit instance data: per class,
|
|
165
|
+
per-property coverage, cardinality, datatypes, node kinds across its
|
|
166
|
+
instances. PROPOSAL ONLY — shapes are builder-owned."""
|
|
167
|
+
explicit = [q for q in await store.match() if q.source != "entailment"]
|
|
168
|
+
predicates_in_use = {q.predicate for q in explicit}
|
|
169
|
+
|
|
170
|
+
# Class -> instance subjects, from EXPLICIT type facts only (closure types
|
|
171
|
+
# would smear subclass properties up into parent shapes).
|
|
172
|
+
instances_of: dict[str, set[str]] = {}
|
|
173
|
+
for q in explicit:
|
|
174
|
+
if q.predicate not in RDF_TYPE or _is_axiom_quad(q, predicates_in_use):
|
|
175
|
+
continue
|
|
176
|
+
instances_of.setdefault(q.object, set()).add(q.subject)
|
|
177
|
+
|
|
178
|
+
shapes: list[NodeShape] = []
|
|
179
|
+
for cls, subjects in instances_of.items():
|
|
180
|
+
if len(subjects) < min_instances:
|
|
181
|
+
continue
|
|
182
|
+
by_subject: dict[str, list[Quad]] = {}
|
|
183
|
+
for q in explicit:
|
|
184
|
+
if q.subject in subjects:
|
|
185
|
+
by_subject.setdefault(q.subject, []).append(q)
|
|
186
|
+
observed: dict[str, dict[str, Any]] = {}
|
|
187
|
+
for quads in by_subject.values():
|
|
188
|
+
by_path: dict[str, list[Quad]] = {}
|
|
189
|
+
for q in quads:
|
|
190
|
+
if q.predicate in RDF_TYPE:
|
|
191
|
+
continue
|
|
192
|
+
by_path.setdefault(q.predicate, []).append(q)
|
|
193
|
+
for path, path_quads in by_path.items():
|
|
194
|
+
o = observed.setdefault(
|
|
195
|
+
path, {"present": 0, "max_seen": 0, "datatypes": set(), "node_kinds": set()}
|
|
196
|
+
)
|
|
197
|
+
o["present"] += 1
|
|
198
|
+
o["max_seen"] = max(o["max_seen"], len(path_quads))
|
|
199
|
+
for q in path_quads:
|
|
200
|
+
o["node_kinds"].add("literal" if is_literal(q.object) else "iri")
|
|
201
|
+
dt = term_datatype(q.object)
|
|
202
|
+
if dt:
|
|
203
|
+
o["datatypes"].add(dt)
|
|
204
|
+
properties: list[PropertyShape] = []
|
|
205
|
+
for path, o in observed.items():
|
|
206
|
+
if o["present"] / len(subjects) < coverage:
|
|
207
|
+
continue
|
|
208
|
+
shape = PropertyShape(path=path)
|
|
209
|
+
if o["present"] == len(subjects):
|
|
210
|
+
shape.min_count = 1
|
|
211
|
+
if o["max_seen"] == 1:
|
|
212
|
+
shape.max_count = 1
|
|
213
|
+
if len(o["datatypes"]) == 1:
|
|
214
|
+
shape.datatype = next(iter(o["datatypes"]))
|
|
215
|
+
if len(o["node_kinds"]) == 1:
|
|
216
|
+
shape.node_kind = next(iter(o["node_kinds"]))
|
|
217
|
+
properties.append(shape)
|
|
218
|
+
if properties:
|
|
219
|
+
shapes.append(NodeShape(target_class=cls, properties=properties))
|
|
220
|
+
return shapes
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class RejectedShape:
|
|
225
|
+
shape: NodeShape
|
|
226
|
+
violations: list[str]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class ShapeAdoption:
|
|
231
|
+
adopted: list[NodeShape] = field(default_factory=list)
|
|
232
|
+
rejected: list[RejectedShape] = field(default_factory=list)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def adopt_shapes(store: InMemoryGraphStore, shapes: list[NodeShape]) -> ShapeAdoption:
|
|
236
|
+
"""Adopt proposed shapes: each shape is validated against the CURRENT
|
|
237
|
+
closure in isolation — a shape the existing data already violates is
|
|
238
|
+
rejected with its violations (1:1 mapping, no guesswork), the clean ones
|
|
239
|
+
are registered. Versioned adoption: migrate_t_box(shapes=...) instead."""
|
|
240
|
+
closure = await store.match()
|
|
241
|
+
adoption = ShapeAdoption()
|
|
242
|
+
for shape in shapes:
|
|
243
|
+
# Validate with ONLY this shape: violations map to it unambiguously.
|
|
244
|
+
violations = InMemoryGraphStore(shapes=[shape]).check_shapes(closure).violations
|
|
245
|
+
if violations:
|
|
246
|
+
adoption.rejected.append(RejectedShape(shape=shape, violations=[v.message for v in violations]))
|
|
247
|
+
else:
|
|
248
|
+
adoption.adopted.append(shape)
|
|
249
|
+
if adoption.adopted:
|
|
250
|
+
store.add_shapes(*adoption.adopted)
|
|
251
|
+
return adoption
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Agent tools
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class _NoArgs(BaseModel):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def review_ontology_tool(store: InMemoryGraphStore) -> Tool:
|
|
264
|
+
"""review_ontology agent tool: inventory + gaps, straight from the store."""
|
|
265
|
+
|
|
266
|
+
async def execute(args: _NoArgs, ctx: ToolContext | None) -> dict[str, Any]:
|
|
267
|
+
review = await review_ontology(store)
|
|
268
|
+
|
|
269
|
+
def stat(t: TermStat) -> dict[str, Any]:
|
|
270
|
+
return {
|
|
271
|
+
"term": t.term,
|
|
272
|
+
"kind": t.kind,
|
|
273
|
+
"uses": t.uses,
|
|
274
|
+
"axiomCount": t.axiom_count,
|
|
275
|
+
**({"label": t.label} if t.label is not None else {}),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
"classes": [stat(t) for t in review.classes],
|
|
280
|
+
"properties": [stat(t) for t in review.properties],
|
|
281
|
+
"undeclared": [stat(t) for t in review.undeclared],
|
|
282
|
+
"unused": review.unused,
|
|
283
|
+
"unlabeled": review.unlabeled,
|
|
284
|
+
"missingDomain": review.missing_domain,
|
|
285
|
+
"missingRange": review.missing_range,
|
|
286
|
+
"explicitQuads": review.explicit_quads,
|
|
287
|
+
"closureQuads": review.closure_quads,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return define_tool(
|
|
291
|
+
name="review_ontology",
|
|
292
|
+
description="Audit the knowledge graph's ontology: classes/properties with usage counts, used-but-undeclared terms, unlabeled/unused terms, properties missing domain or range. Read-only.",
|
|
293
|
+
schema=_NoArgs,
|
|
294
|
+
execute=execute,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def propose_shapes_tool(
|
|
299
|
+
store: InMemoryGraphStore,
|
|
300
|
+
min_instances: int = 2,
|
|
301
|
+
coverage: float = 0.5,
|
|
302
|
+
) -> Tool:
|
|
303
|
+
"""propose_shapes agent tool: SHACL candidates induced from instance data."""
|
|
304
|
+
|
|
305
|
+
async def shape_json(s: NodeShape) -> dict[str, Any]:
|
|
306
|
+
return {
|
|
307
|
+
"targetClass": s.target_class,
|
|
308
|
+
"properties": [
|
|
309
|
+
{k: v for k, v in p.__dict__.items() if v is not None} for p in s.properties
|
|
310
|
+
],
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async def execute(args: _NoArgs, ctx: ToolContext | None) -> list[dict[str, Any]]:
|
|
314
|
+
return [await shape_json(s) for s in await propose_shapes(store, min_instances, coverage)]
|
|
315
|
+
|
|
316
|
+
return define_tool(
|
|
317
|
+
name="propose_shapes",
|
|
318
|
+
description="Induce candidate SHACL shapes from existing instance data (>= 2 instances per class, >= 50% property coverage). PROPOSAL: adoption is the builder's call via adopt_shapes(), never automatic.",
|
|
319
|
+
schema=_NoArgs,
|
|
320
|
+
execute=execute,
|
|
321
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Multi-agent orchestration. Parity: packages/typescript/src/orchestration/index.ts.
|
|
2
|
+
|
|
3
|
+
Strategies:
|
|
4
|
+
- "sequential": chained — each agent gets the previous agent's text appended
|
|
5
|
+
to the original input; combined text is the LAST result.
|
|
6
|
+
- "parallel": fan-out with a concurrency cap; results keep agent order.
|
|
7
|
+
- "router": one model call picks an agent by index; only that agent runs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import inspect
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Literal, Optional
|
|
18
|
+
|
|
19
|
+
from .agent import Agent, AgentResult
|
|
20
|
+
from .errors import AgentError
|
|
21
|
+
from .models import CompleteOptions, ModelProvider
|
|
22
|
+
from .types import ChatMessage
|
|
23
|
+
|
|
24
|
+
OrchestrationStrategy = Literal["sequential", "parallel", "router"]
|
|
25
|
+
|
|
26
|
+
ROUTER_SYSTEM = "You route user requests to specialist agents. Reply with ONLY the agent number."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class OrchestrationPlan:
|
|
31
|
+
strategy: OrchestrationStrategy
|
|
32
|
+
agents: list[Agent] = field(default_factory=list)
|
|
33
|
+
"""Max concurrent agents for parallel. Default: all at once."""
|
|
34
|
+
concurrency: Optional[int] = None
|
|
35
|
+
"""Model that picks the agent for router. Defaults to the first agent's model."""
|
|
36
|
+
router_model: Optional[ModelProvider] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class OrchestratedResult:
|
|
41
|
+
strategy: OrchestrationStrategy
|
|
42
|
+
results: list[AgentResult]
|
|
43
|
+
"""Combined text: last result (sequential/router), joined (parallel)."""
|
|
44
|
+
text: str
|
|
45
|
+
"""Router only: absolute index of the chosen agent."""
|
|
46
|
+
routed_to: Optional[int] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _require_agents(plan: OrchestrationPlan) -> None:
|
|
50
|
+
if not plan.agents:
|
|
51
|
+
raise AgentError("ORCHESTRATION_EMPTY", "Orchestration needs at least one agent", retryable=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _description_of(agent: Any) -> str:
|
|
55
|
+
opts = getattr(agent, "options", None)
|
|
56
|
+
desc = getattr(opts, "description", "") if opts is not None else ""
|
|
57
|
+
if desc:
|
|
58
|
+
return str(desc)
|
|
59
|
+
fallback = getattr(agent, "description", "")
|
|
60
|
+
return str(fallback) if fallback else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _model_of(agent: Any) -> Optional[ModelProvider]:
|
|
64
|
+
opts = getattr(agent, "options", None)
|
|
65
|
+
model = getattr(opts, "model", None) if opts is not None else None
|
|
66
|
+
if model is None:
|
|
67
|
+
model = getattr(agent, "model", None)
|
|
68
|
+
return model
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _call_run(agent: Any, text: str, invocation_state: Optional[dict[str, Any]]) -> Any:
|
|
72
|
+
"""Agent.run takes no invocation-state knob; pass it when the callee does.
|
|
73
|
+
|
|
74
|
+
Members of the plan may be plain Agent instances (run(text)) or richer
|
|
75
|
+
runtimes that accept an invocation-state keyword.
|
|
76
|
+
"""
|
|
77
|
+
if invocation_state is not None:
|
|
78
|
+
try:
|
|
79
|
+
sig = inspect.signature(agent.run)
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
sig = None
|
|
82
|
+
if sig is not None and any(
|
|
83
|
+
p.kind is inspect.Parameter.VAR_KEYWORD or p.name == "invocation_state"
|
|
84
|
+
for p in sig.parameters.values()
|
|
85
|
+
):
|
|
86
|
+
return await agent.run(text, invocation_state=invocation_state)
|
|
87
|
+
return await agent.run(text)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _run_sequential(
|
|
91
|
+
plan: OrchestrationPlan, input_text: str, invocation_state: Optional[dict[str, Any]]
|
|
92
|
+
) -> OrchestratedResult:
|
|
93
|
+
"""Chain: each agent receives the previous agent's text appended to the input."""
|
|
94
|
+
_require_agents(plan)
|
|
95
|
+
results: list[AgentResult] = []
|
|
96
|
+
current = input_text
|
|
97
|
+
for i, agent in enumerate(plan.agents):
|
|
98
|
+
res = await _call_run(agent, current, invocation_state)
|
|
99
|
+
results.append(res)
|
|
100
|
+
current = f"{input_text}\n\nPrevious result ({agent.name}):\n{res.text}"
|
|
101
|
+
return OrchestratedResult("sequential", results, results[-1].text if results else "")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _run_parallel(
|
|
105
|
+
plan: OrchestrationPlan, input_text: str, invocation_state: Optional[dict[str, Any]]
|
|
106
|
+
) -> OrchestratedResult:
|
|
107
|
+
"""Fan-out with a concurrency cap; results keep agent order."""
|
|
108
|
+
_require_agents(plan)
|
|
109
|
+
count = len(plan.agents)
|
|
110
|
+
limit = plan.concurrency if plan.concurrency is not None else count
|
|
111
|
+
semaphore = asyncio.Semaphore(max(1, min(max(1, limit), count)))
|
|
112
|
+
results: list[AgentResult] = [None] * count # type: ignore[list-item]
|
|
113
|
+
|
|
114
|
+
async def run_one(i: int, agent: Any) -> None:
|
|
115
|
+
async with semaphore:
|
|
116
|
+
results[i] = await _call_run(agent, input_text, invocation_state)
|
|
117
|
+
|
|
118
|
+
await asyncio.gather(*(run_one(i, a) for i, a in enumerate(plan.agents)))
|
|
119
|
+
return OrchestratedResult("parallel", results, "\n\n".join(r.text for r in results))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _run_router(
|
|
123
|
+
plan: OrchestrationPlan, input_text: str, invocation_state: Optional[dict[str, Any]]
|
|
124
|
+
) -> OrchestratedResult:
|
|
125
|
+
"""One model call picks the agent by index; only that agent runs."""
|
|
126
|
+
_require_agents(plan)
|
|
127
|
+
router_model = plan.router_model or _model_of(plan.agents[0])
|
|
128
|
+
if router_model is None:
|
|
129
|
+
raise AgentError("ROUTER_NO_MATCH", "Router strategy needs a model", retryable=False)
|
|
130
|
+
menu = "\n".join(f"{i}: {a.name}" + (f" — {_description_of(a)}" if _description_of(a) else "")
|
|
131
|
+
for i, a in enumerate(plan.agents))
|
|
132
|
+
pick = await router_model.complete(
|
|
133
|
+
CompleteOptions(
|
|
134
|
+
messages=[
|
|
135
|
+
ChatMessage(role="system", content=ROUTER_SYSTEM),
|
|
136
|
+
ChatMessage(role="user", content=f"Agents:\n{menu}\n\nRequest: {input_text}"),
|
|
137
|
+
],
|
|
138
|
+
tools=[],
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
match = re.search(r"\d+", pick.text or "")
|
|
142
|
+
idx = int(match.group(0)) if match else -1
|
|
143
|
+
if not (0 <= idx < len(plan.agents)):
|
|
144
|
+
raise AgentError("ROUTER_NO_MATCH", f"Router picked invalid agent: {json.dumps(pick.text)}", retryable=False)
|
|
145
|
+
res = await _call_run(plan.agents[idx], input_text, invocation_state)
|
|
146
|
+
return OrchestratedResult("router", [res], getattr(res, "text", ""), routed_to=idx)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def run_orchestrated(
|
|
150
|
+
plan: OrchestrationPlan,
|
|
151
|
+
input_text: str,
|
|
152
|
+
invocation_state: Optional[dict[str, Any]] = None,
|
|
153
|
+
) -> OrchestratedResult:
|
|
154
|
+
"""Run a multi-agent plan. invocation_state threads to agents that accept it."""
|
|
155
|
+
if plan.strategy == "sequential":
|
|
156
|
+
return await _run_sequential(plan, input_text, invocation_state)
|
|
157
|
+
if plan.strategy == "parallel":
|
|
158
|
+
return await _run_parallel(plan, input_text, invocation_state)
|
|
159
|
+
if plan.strategy == "router":
|
|
160
|
+
return await _run_router(plan, input_text, invocation_state)
|
|
161
|
+
raise AgentError("ORCHESTRATION_STRATEGY", f"Unknown strategy: {plan.strategy}", retryable=False)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = [
|
|
165
|
+
"OrchestrationPlan",
|
|
166
|
+
"OrchestrationStrategy",
|
|
167
|
+
"OrchestratedResult",
|
|
168
|
+
"run_orchestrated",
|
|
169
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Per-model prices + cost math. Parity: src/models/pricing.ts (post-fix).
|
|
2
|
+
|
|
3
|
+
Pinned semantics:
|
|
4
|
+
- unknown models -> None (NEVER a misleading zero)
|
|
5
|
+
- local ollama: free first, name collisions with cloud models lose to it
|
|
6
|
+
- reasoning tokens are already inside output_tokens: never added on top
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from .types import TokenUsage as _TokenUsage # type alias used by call sites
|
|
16
|
+
|
|
17
|
+
TokenUsage = _TokenUsage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModelPrice(BaseModel):
|
|
21
|
+
input_per_million: float
|
|
22
|
+
output_per_million: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# USD per 1M tokens. Providers send token counts ONLY — cost is always
|
|
26
|
+
# computed client-side against a table. Verify against your provider bill.
|
|
27
|
+
_PRICES: dict[str, ModelPrice] = {
|
|
28
|
+
"Qwen/Qwen3.5-9B": ModelPrice(input_per_million=0.2, output_per_million=0.8),
|
|
29
|
+
"meta-llama/Llama-3.3-70B-Instruct-Turbo": ModelPrice(input_per_million=0.88, output_per_million=0.88),
|
|
30
|
+
"deepseek-ai/DeepSeek-V4.1-Flash": ModelPrice(input_per_million=0.2, output_per_million=0.6),
|
|
31
|
+
"zai-org/GLM-5.3-Flash": ModelPrice(input_per_million=0.2, output_per_million=0.6),
|
|
32
|
+
"gpt-4o-mini": ModelPrice(input_per_million=0.15, output_per_million=0.6),
|
|
33
|
+
"claude-sonnet-4-6": ModelPrice(input_per_million=3, output_per_million=15),
|
|
34
|
+
"qwen2.5": ModelPrice(input_per_million=0, output_per_million=0),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_FREE = ModelPrice(input_per_million=0, output_per_million=0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def price_for(model_id: str) -> Optional[ModelPrice]:
|
|
41
|
+
"""Look up a price by bare model id (`provider:` prefixes are stripped).
|
|
42
|
+
|
|
43
|
+
Local ollama first: even a name colliding with a cloud model (e.g.
|
|
44
|
+
`ollama:gpt-4o-mini`) runs free, so it must win over the priced table.
|
|
45
|
+
"""
|
|
46
|
+
if model_id.startswith("ollama:"):
|
|
47
|
+
return _FREE
|
|
48
|
+
bare = model_id.split(":", 1)[1] if ":" in model_id else model_id
|
|
49
|
+
return _PRICES.get(bare)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def usage_cost(usage, model_id: str, override: Optional[ModelPrice] = None) -> Optional[float]:
|
|
53
|
+
"""USD cost for usage; unknown models return None (never a fake zero).
|
|
54
|
+
|
|
55
|
+
`output_tokens` already includes reasoning tokens on every supported
|
|
56
|
+
provider, so they are billed at the output rate with no extra addition.
|
|
57
|
+
"""
|
|
58
|
+
price = override or price_for(model_id)
|
|
59
|
+
if price is None:
|
|
60
|
+
return None
|
|
61
|
+
return (
|
|
62
|
+
usage.input_tokens * price.input_per_million + usage.output_tokens * price.output_per_million
|
|
63
|
+
) / 1_000_000
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Prompts: versioned, renderable templates. No magic strings in agent code.
|
|
2
|
+
|
|
3
|
+
Parity: packages/typescript/src/prompts/index.ts. Sources: inline
|
|
4
|
+
PromptTemplate, markdown files with frontmatter (BOM/CRLF tolerant), A/B
|
|
5
|
+
variants (weighted random — unbiased via uniform roll over the weight total).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?(.*)$", re.DOTALL)
|
|
17
|
+
VAR_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PromptTemplate:
|
|
22
|
+
name: str
|
|
23
|
+
template: str
|
|
24
|
+
version: str = "0.1.0"
|
|
25
|
+
|
|
26
|
+
def render(self, vars: Optional[dict[str, Any]] = None) -> str:
|
|
27
|
+
values = vars or {}
|
|
28
|
+
|
|
29
|
+
def sub(match: re.Match[str]) -> str:
|
|
30
|
+
key = match.group(1)
|
|
31
|
+
if key not in values:
|
|
32
|
+
raise ValueError(f"Missing prompt var: {key} (prompt: {self.name})")
|
|
33
|
+
return str(values[key])
|
|
34
|
+
|
|
35
|
+
return VAR_RE.sub(sub, self.template)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def define_prompt(name: str, template: str, version: str = "0.1.0") -> PromptTemplate:
|
|
39
|
+
"""TS parity helper: template + render raising on a missing {{var}}."""
|
|
40
|
+
return PromptTemplate(name=name, template=template, version=version)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _strip_bom(content: str) -> str:
|
|
44
|
+
return content[1:] if content.startswith("") else content
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_meta(fm: str) -> dict[str, str]:
|
|
48
|
+
meta: dict[str, str] = {}
|
|
49
|
+
for line in fm.split("\n"):
|
|
50
|
+
i = line.find(":")
|
|
51
|
+
if i > 0:
|
|
52
|
+
key = line[:i].strip()
|
|
53
|
+
value = line[i + 1 :].strip()
|
|
54
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
55
|
+
value.startswith("'") and value.endswith("'")
|
|
56
|
+
):
|
|
57
|
+
value = value[1:-1]
|
|
58
|
+
meta[key] = value
|
|
59
|
+
return meta
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_prompt_file(path: str | Path) -> PromptTemplate:
|
|
63
|
+
"""Load `--- name, version ---` frontmatter + markdown body from disk."""
|
|
64
|
+
raw = _strip_bom(Path(path).read_text(encoding="utf-8"))
|
|
65
|
+
match = FRONTMATTER_RE.match(raw)
|
|
66
|
+
if not match:
|
|
67
|
+
raise ValueError(f"Invalid prompt file (missing frontmatter): {path}")
|
|
68
|
+
meta = _parse_meta(match.group(1))
|
|
69
|
+
if not meta.get("name"):
|
|
70
|
+
raise ValueError(f"Invalid prompt file (missing name): {path}")
|
|
71
|
+
return define_prompt(meta["name"], match.group(2).strip(), meta.get("version") or "0.1.0")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _variant_fields(variant: Any) -> tuple[str, str, Optional[str], Optional[float]]:
|
|
75
|
+
if isinstance(variant, dict):
|
|
76
|
+
return (
|
|
77
|
+
variant.get("name", ""),
|
|
78
|
+
variant.get("template", ""),
|
|
79
|
+
variant.get("version"),
|
|
80
|
+
variant.get("weight"),
|
|
81
|
+
)
|
|
82
|
+
return (
|
|
83
|
+
getattr(variant, "name", ""),
|
|
84
|
+
getattr(variant, "template", ""),
|
|
85
|
+
getattr(variant, "version", None),
|
|
86
|
+
getattr(variant, "weight", None),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def ab_prompts(
|
|
91
|
+
variants: list[Any],
|
|
92
|
+
pick: Optional[str] = None,
|
|
93
|
+
weights: Optional[list[float]] = None,
|
|
94
|
+
) -> PromptTemplate:
|
|
95
|
+
"""A/B variants: pick by weighted traffic split, or an explicit name.
|
|
96
|
+
|
|
97
|
+
`pick` matches "name" or "name@version". `weights` (parallel to variants)
|
|
98
|
+
overrides per-variant weights; default weight is 1. The roll is a single
|
|
99
|
+
uniform draw over the weight total — the interval walk is unbiased.
|
|
100
|
+
"""
|
|
101
|
+
if not variants:
|
|
102
|
+
raise ValueError("ab_prompts needs at least one variant")
|
|
103
|
+
if pick:
|
|
104
|
+
for variant in variants:
|
|
105
|
+
name, template, version, _ = _variant_fields(variant)
|
|
106
|
+
label = f"{name}@{version}" if version else name
|
|
107
|
+
if label == pick or name == pick:
|
|
108
|
+
return define_prompt(name, template, version or "0.1.0")
|
|
109
|
+
raise ValueError(f"Unknown prompt variant: {pick}")
|
|
110
|
+
|
|
111
|
+
effective = [
|
|
112
|
+
weights[i] if weights is not None and i < len(weights) else (_variant_fields(v)[3] or 1)
|
|
113
|
+
for i, v in enumerate(variants)
|
|
114
|
+
]
|
|
115
|
+
total = sum(effective)
|
|
116
|
+
roll = random.uniform(0, total)
|
|
117
|
+
for variant, weight in zip(variants, effective):
|
|
118
|
+
roll -= weight
|
|
119
|
+
if roll <= 0:
|
|
120
|
+
name, template, version, _ = _variant_fields(variant)
|
|
121
|
+
return define_prompt(name, template, version or "0.1.0")
|
|
122
|
+
# Floating-point misses are astronomically rare; deterministic tail.
|
|
123
|
+
name, template, version, _ = _variant_fields(variants[-1])
|
|
124
|
+
return define_prompt(name, template, version or "0.1.0")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = [
|
|
128
|
+
"PromptTemplate",
|
|
129
|
+
"ab_prompts",
|
|
130
|
+
"define_prompt",
|
|
131
|
+
"load_prompt_file",
|
|
132
|
+
]
|