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,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
+ ]