forum-engine 1.4.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.
forum/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Forum: accountable multi-agent orchestration engine (pure core)."""
2
+
3
+ __version__ = "1.4.0"
forum/actor.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ _STOP = object()
7
+
8
+
9
+ class Actor:
10
+ """A minimal mailbox actor: an async receive loop over a queue."""
11
+
12
+ def __init__(self, name: str) -> None:
13
+ self.name = name
14
+ self.inbox: asyncio.Queue[Any] = asyncio.Queue()
15
+ self._task: asyncio.Task | None = None
16
+ self.error: BaseException | None = None
17
+
18
+ async def on_message(self, message: Any) -> None:
19
+ raise NotImplementedError
20
+
21
+ async def _loop(self) -> None:
22
+ while True:
23
+ message = await self.inbox.get()
24
+ if message is _STOP:
25
+ break
26
+ try:
27
+ await self.on_message(message)
28
+ except Exception as exc: # let-it-crash, but observable
29
+ self.error = exc
30
+ break
31
+
32
+ def start(self) -> "Actor":
33
+ self._task = asyncio.create_task(self._loop())
34
+ return self
35
+
36
+ async def send(self, message: Any) -> None:
37
+ await self.inbox.put(message)
38
+
39
+ async def stop(self) -> None:
40
+ if self._task is None:
41
+ return
42
+ await self.inbox.put(_STOP)
43
+ await self._task
forum/api_executor.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import urllib.request
7
+
8
+ from forum.executor import Assignment, Result
9
+
10
+
11
+ class ApiExecutor:
12
+ """Drive a model via the Anthropic Messages API over stdlib ``urllib``.
13
+
14
+ Network IO lives here, at the edge. ``opener`` is a callable
15
+ ``(urllib.request.Request) -> bytes`` that performs the request; it is
16
+ injected in tests so no real network is hit. The default opens the request
17
+ with ``urllib.request.urlopen``. The blocking call runs off the event loop
18
+ via ``asyncio.to_thread``.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ model: str = "claude-sonnet-4-6",
24
+ *,
25
+ api_key_env: str = "ANTHROPIC_API_KEY",
26
+ base_url: str = "https://api.anthropic.com/v1/messages",
27
+ max_tokens: int = 1024,
28
+ opener=None,
29
+ ) -> None:
30
+ self._model = model
31
+ self._api_key_env = api_key_env
32
+ self._base_url = base_url
33
+ self._max_tokens = max_tokens
34
+ self._opener = opener or _default_opener
35
+
36
+ @property
37
+ def model_id(self) -> str:
38
+ return self._model
39
+
40
+ async def run(self, assignment: Assignment) -> Result:
41
+ request = self._build_request(assignment.instruction)
42
+ try:
43
+ raw = await asyncio.to_thread(self._opener, request)
44
+ except Exception as exc:
45
+ return Result(assignment.task_id, assignment.agent, f"error: {exc}", ok=False)
46
+ text = _extract_text(raw)
47
+ if text is None:
48
+ preview = raw.decode("utf-8", "replace") if isinstance(raw, (bytes, bytearray)) else str(raw)
49
+ return Result(
50
+ assignment.task_id, assignment.agent,
51
+ f"error: unexpected API response shape: {preview[:200]!r}", ok=False,
52
+ )
53
+ return Result(assignment.task_id, assignment.agent, text, ok=True)
54
+
55
+ def _build_request(self, instruction: str) -> urllib.request.Request:
56
+ body = json.dumps(
57
+ {
58
+ "model": self._model,
59
+ "max_tokens": self._max_tokens,
60
+ "messages": [{"role": "user", "content": instruction}],
61
+ }
62
+ ).encode("utf-8")
63
+ headers = {
64
+ "content-type": "application/json",
65
+ "anthropic-version": "2023-06-01",
66
+ "x-api-key": os.environ.get(self._api_key_env, ""),
67
+ }
68
+ return urllib.request.Request(self._base_url, data=body, headers=headers, method="POST")
69
+
70
+
71
+ def _extract_text(raw) -> str | None:
72
+ """Pull the assistant text from an Anthropic Messages response, or None if
73
+ the payload is not the expected {"content": [{"text": ...}]} shape."""
74
+ try:
75
+ data = json.loads(raw)
76
+ content = data["content"]
77
+ if isinstance(content, list) and content and isinstance(content[0], dict) and "text" in content[0]:
78
+ return content[0]["text"]
79
+ except (json.JSONDecodeError, KeyError, TypeError):
80
+ return None
81
+ return None
82
+
83
+
84
+ def _default_opener(request: urllib.request.Request) -> bytes:
85
+ with urllib.request.urlopen(request) as response:
86
+ return response.read()
forum/budget.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class RunBudget:
8
+ """A ceiling on a single run, so a loop cannot quietly run away.
9
+
10
+ ``max_model_calls`` caps how many times the executor is invoked across the
11
+ whole run (planning, dispatch, validation, synthesis); it is deterministic
12
+ and is the cost-relevant dimension. ``max_seconds`` is a best-effort wall
13
+ clock cap. A breach of either stops the run gracefully and is witnessed in
14
+ the ledger as a ``budget`` entry; the run stays verifiable.
15
+ """
16
+
17
+ max_model_calls: int | None = None
18
+ max_seconds: float | None = None
forum/chat_executor.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import urllib.request
7
+
8
+ from forum.executor import Assignment, Result
9
+
10
+
11
+ class ChatExecutor:
12
+ """Drive any OpenAI-compatible chat-completions endpoint over stdlib urllib.
13
+
14
+ This is the model-agnostic path. It speaks the widely-implemented
15
+ ``/v1/chat/completions`` protocol, so it works with local servers that need
16
+ no account (Ollama, LM Studio, llama.cpp, vLLM) and with OpenAI-compatible
17
+ cloud providers alike. Point ``base_url`` at your server and name the model.
18
+ An API key is optional (local servers usually need none); when ``api_key_env``
19
+ names a non-empty environment variable, its value is sent as a Bearer token.
20
+
21
+ Network IO lives here, at the edge. ``opener`` is a callable
22
+ ``(urllib.request.Request) -> bytes`` injected in tests so no real network is
23
+ hit; the blocking call runs off the event loop via ``asyncio.to_thread``.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ model: str,
29
+ *,
30
+ base_url: str = "http://localhost:11434/v1/chat/completions",
31
+ api_key_env: str | None = None,
32
+ max_tokens: int = 1024,
33
+ opener=None,
34
+ ) -> None:
35
+ self._model = model
36
+ self._base_url = base_url
37
+ self._api_key_env = api_key_env
38
+ self._max_tokens = max_tokens
39
+ self._opener = opener or _default_opener
40
+
41
+ @property
42
+ def model_id(self) -> str:
43
+ return self._model
44
+
45
+ async def run(self, assignment: Assignment) -> Result:
46
+ request = self._build_request(assignment.instruction)
47
+ try:
48
+ raw = await asyncio.to_thread(self._opener, request)
49
+ except Exception as exc:
50
+ return Result(assignment.task_id, assignment.agent, f"error: {exc}", ok=False)
51
+ text = _extract_text(raw)
52
+ if text is None:
53
+ preview = raw.decode("utf-8", "replace") if isinstance(raw, (bytes, bytearray)) else str(raw)
54
+ return Result(
55
+ assignment.task_id, assignment.agent,
56
+ f"error: unexpected chat response shape: {preview[:200]!r}", ok=False,
57
+ )
58
+ return Result(assignment.task_id, assignment.agent, text, ok=True)
59
+
60
+ def _build_request(self, instruction: str) -> urllib.request.Request:
61
+ body = json.dumps(
62
+ {
63
+ "model": self._model,
64
+ # max_tokens is accepted by OpenAI (back-compat) and by every local
65
+ # server we target; newer OpenAI models also accept max_completion_tokens.
66
+ "max_tokens": self._max_tokens,
67
+ "messages": [{"role": "user", "content": instruction}],
68
+ }
69
+ ).encode("utf-8")
70
+ headers = {"content-type": "application/json"}
71
+ if self._api_key_env:
72
+ key = os.environ.get(self._api_key_env, "")
73
+ if key:
74
+ headers["authorization"] = f"Bearer {key}"
75
+ return urllib.request.Request(self._base_url, data=body, headers=headers, method="POST")
76
+
77
+
78
+ def _extract_text(raw) -> str | None:
79
+ """Pull the assistant text from an OpenAI-compatible chat-completions reply,
80
+ or None if the payload is not the expected choices[0].message.content shape."""
81
+ try:
82
+ data = json.loads(raw)
83
+ choices = data["choices"]
84
+ if isinstance(choices, list) and choices and isinstance(choices[0], dict):
85
+ message = choices[0].get("message")
86
+ if isinstance(message, dict):
87
+ content = message.get("content")
88
+ if isinstance(content, str):
89
+ return content
90
+ if isinstance(content, list):
91
+ # some OpenAI-compatible gateways return content as a list of
92
+ # {type, text} parts; join the text parts
93
+ joined = "".join(p.get("text", "") for p in content if isinstance(p, dict))
94
+ if joined:
95
+ return joined
96
+ except (json.JSONDecodeError, KeyError, TypeError):
97
+ return None
98
+ return None
99
+
100
+
101
+ def _default_opener(request: urllib.request.Request) -> bytes:
102
+ with urllib.request.urlopen(request) as response:
103
+ return response.read()
forum/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import dataclasses
6
+ import json
7
+ import shlex
8
+ import sys
9
+
10
+ from forum import __version__
11
+
12
+ DEFAULT_LEDGER = "forum-ledger"
13
+
14
+
15
+ def _make_executor(args):
16
+ """Pick an executor from flags (the first present wins): --chat-url, --api, --cmd, else None.
17
+
18
+ Forum is model-agnostic: --cmd runs any command (a local model CLI needs no
19
+ account), --chat-url talks to any OpenAI-compatible server (local or cloud),
20
+ and --api is one specific provider (Anthropic).
21
+ """
22
+ chat_url = getattr(args, "chat_url", None)
23
+ if chat_url:
24
+ from forum.chat_executor import ChatExecutor
25
+
26
+ return ChatExecutor(
27
+ getattr(args, "model", None) or "default",
28
+ base_url=chat_url,
29
+ api_key_env=getattr(args, "api_key_env", None),
30
+ )
31
+ if getattr(args, "api", False):
32
+ from forum.api_executor import ApiExecutor
33
+
34
+ return ApiExecutor(args.model) if getattr(args, "model", None) else ApiExecutor()
35
+ cmd = getattr(args, "cmd", None)
36
+ if cmd:
37
+ from forum.executor import SubprocessExecutor
38
+
39
+ return SubprocessExecutor(shlex.split(cmd))
40
+ return None
41
+
42
+
43
+ def _open_ledger(directory):
44
+ from forum.ledger import Ledger
45
+ from forum.storage import FileStorage
46
+
47
+ return Ledger(FileStorage(directory))
48
+
49
+
50
+ def _cmd_route(args) -> int:
51
+ from forum.roster import load_default
52
+ from forum.routing import LexicalRouter
53
+
54
+ result = LexicalRouter().score(args.text, load_default())
55
+ print(json.dumps({
56
+ "decided": result.decided,
57
+ "confidence": result.confidence,
58
+ "needs_escalation": result.needs_escalation,
59
+ "candidates": [{"agent": c.agent, "score": c.score} for c in result.candidates],
60
+ }, indent=2))
61
+ return 0
62
+
63
+
64
+ def _cmd_submit(args) -> int:
65
+ executor = _make_executor(args)
66
+ if executor is None:
67
+ print(
68
+ "submit needs a model executor. Forum is model-agnostic: pass --cmd "
69
+ '"<model cli>" (any command, local models need no account), --chat-url '
70
+ "<openai-compatible url> (e.g. a local Ollama server), or --api (Anthropic).",
71
+ file=sys.stderr,
72
+ )
73
+ return 2
74
+ from forum.budget import RunBudget
75
+ from forum.daemon import build_orchestrator
76
+
77
+ budget = None
78
+ if args.max_model_calls is not None or args.max_seconds is not None:
79
+ budget = RunBudget(max_model_calls=args.max_model_calls, max_seconds=args.max_seconds)
80
+ orch = build_orchestrator(args.ledger, executor=executor)
81
+ try:
82
+ answer = asyncio.run(orch.submit(args.request, budget=budget))
83
+ except ValueError as exc:
84
+ print(f"submit failed: {exc}", file=sys.stderr)
85
+ return 1
86
+ print(answer)
87
+ print(f"checkpoint: {orch.ledger.checkpoint()}", file=sys.stderr)
88
+ return 0
89
+
90
+
91
+ def _cmd_serve(args) -> int:
92
+ from forum.daemon import serve
93
+
94
+ asyncio.run(serve(
95
+ ledger_dir=args.ledger, host=args.host, port=args.port, executor=_make_executor(args)
96
+ ))
97
+ return 0
98
+
99
+
100
+ def _cmd_mcp(args) -> int:
101
+ from forum.daemon import build_orchestrator
102
+ from forum.mcp_surface import serve_stdio
103
+
104
+ orch = build_orchestrator(args.ledger, executor=_make_executor(args))
105
+ asyncio.run(serve_stdio(orch))
106
+ return 0
107
+
108
+
109
+ def _cmd_ledger_verify(args) -> int:
110
+ led = _open_ledger(args.ledger)
111
+ print(json.dumps({"chain": led.verify(), "deep": led.verify(deep=True)}, indent=2))
112
+ return 0
113
+
114
+
115
+ def _cmd_ledger_show(args) -> int:
116
+ led = _open_ledger(args.ledger)
117
+ entries = led.replay()
118
+ if args.limit:
119
+ entries = entries[-args.limit:]
120
+ for e in entries:
121
+ print(f"{e.seq:>5} {e.actor:<12} {e.kind:<10} parent={e.causal_parent}")
122
+ return 0
123
+
124
+
125
+ def _cmd_ledger_replay(args) -> int:
126
+ led = _open_ledger(args.ledger)
127
+ entries = led.replay(until=args.seq)
128
+ print(json.dumps([dataclasses.asdict(e) for e in entries], indent=2))
129
+ return 0
130
+
131
+
132
+ def _cmd_ledger_get(args) -> int:
133
+ led = _open_ledger(args.ledger)
134
+ try:
135
+ entry = led.get(args.seq)
136
+ except KeyError:
137
+ print(f"no ledger entry at seq {args.seq}", file=sys.stderr)
138
+ return 1
139
+ print(json.dumps(dataclasses.asdict(entry), indent=2))
140
+ return 0
141
+
142
+
143
+ def _cmd_ledger_summary(args) -> int:
144
+ from forum.report import summarize
145
+
146
+ s = summarize(_open_ledger(args.ledger))
147
+ if args.json:
148
+ print(json.dumps(s, indent=2))
149
+ return 0
150
+ print(f"entries: {s['entries']}")
151
+ print(f"requests: {s['requests']} | plans: {s['plans']} | tasks: {s['tasks']}")
152
+ print(f"task results: {s['task_results']} (failed {s['failed_results']}) | verdicts: pass {s['verdicts_pass']} / fail {s['verdicts_fail']}")
153
+ print(f"intent checks: {s['intent_checks']} (flagged {s['intent_flagged']})")
154
+ print(f"escalations: {s['escalations']} | budget stops: {s['budget_stops']} | contexts: {s['contexts']} | answers: {s['answers']}")
155
+ print(f"model calls: {s['model_calls']}")
156
+ print(f"checkpoint: {s['checkpoint'][:16]}... | verified: {s['verified']}")
157
+ return 0
158
+
159
+
160
+ def _cmd_bench(args) -> int:
161
+ from forum.report import compare, summarize
162
+
163
+ a = summarize(_open_ledger(args.a))
164
+ b = summarize(_open_ledger(args.b))
165
+ delta = compare(a, b)
166
+ if args.json:
167
+ print(json.dumps({"a": a, "b": b, "delta": delta}, indent=2))
168
+ return 0
169
+ print(f"{'metric':<16}{'A':>8}{'B':>8}{'delta':>8}")
170
+ for key in delta:
171
+ print(f"{key:<16}{a.get(key, 0):>8}{b.get(key, 0):>8}{delta[key]:>+8}")
172
+ return 0
173
+
174
+
175
+ def _add_ledger(sp) -> None:
176
+ sp.add_argument("--ledger", default=DEFAULT_LEDGER, help="ledger directory (default: forum-ledger)")
177
+
178
+
179
+ def _add_executor(sp) -> None:
180
+ sp.add_argument("--cmd", default=None, help='run any model command per task, e.g. --cmd "ollama run llama3" (no account needed)')
181
+ sp.add_argument("--chat-url", default=None, help="an OpenAI-compatible chat-completions URL, e.g. a local Ollama or LM Studio server (no account needed)")
182
+ sp.add_argument("--api", action="store_true", help="use the Anthropic API executor (reads ANTHROPIC_API_KEY)")
183
+ sp.add_argument("--model", default=None, help="model id for --chat-url or --api")
184
+ sp.add_argument("--api-key-env", default=None, help="env var holding a Bearer key for --chat-url (optional; local servers need none)")
185
+
186
+
187
+ def _print_help_rc(parser: argparse.ArgumentParser) -> int:
188
+ parser.print_help()
189
+ return 1
190
+
191
+
192
+ def build_parser() -> argparse.ArgumentParser:
193
+ parser = argparse.ArgumentParser(prog="forum", description="Forum: accountable multi-agent orchestration.")
194
+ parser.add_argument("--version", action="version", version=f"forum {__version__}")
195
+ sub = parser.add_subparsers(dest="command")
196
+
197
+ route = sub.add_parser("route", help="route a request to a capability lane (no model needed)")
198
+ route.add_argument("text")
199
+ route.set_defaults(func=_cmd_route)
200
+
201
+ submit = sub.add_parser("submit", help="plan and answer a request, witnessed")
202
+ submit.add_argument("request")
203
+ submit.add_argument("--max-model-calls", type=int, default=None, help="bound the run to N model calls (witnessed budget)")
204
+ submit.add_argument("--max-seconds", type=float, default=None, help="bound the run to S seconds (best-effort)")
205
+ _add_ledger(submit)
206
+ _add_executor(submit)
207
+ submit.set_defaults(func=_cmd_submit)
208
+
209
+ serve = sub.add_parser("serve", help="run the HTTP daemon")
210
+ serve.add_argument("--host", default="127.0.0.1")
211
+ serve.add_argument("--port", type=int, default=8080)
212
+ _add_ledger(serve)
213
+ _add_executor(serve)
214
+ serve.set_defaults(func=_cmd_serve)
215
+
216
+ mcp = sub.add_parser("mcp", help="run the MCP (stdio) server")
217
+ _add_ledger(mcp)
218
+ _add_executor(mcp)
219
+ mcp.set_defaults(func=_cmd_mcp)
220
+
221
+ ledger = sub.add_parser("ledger", help="inspect the ledger")
222
+ lsub = ledger.add_subparsers(dest="ledger_command")
223
+ verify = lsub.add_parser("verify", help="verify the chain and payloads")
224
+ _add_ledger(verify)
225
+ verify.set_defaults(func=_cmd_ledger_verify)
226
+ show = lsub.add_parser("show", help="list entries (seq, actor, kind)")
227
+ _add_ledger(show)
228
+ show.add_argument("--limit", type=int, default=0, help="show only the last N entries")
229
+ show.set_defaults(func=_cmd_ledger_show)
230
+ replay = lsub.add_parser("replay", help="dump entries up to a seq")
231
+ _add_ledger(replay)
232
+ replay.add_argument("seq", type=int)
233
+ replay.set_defaults(func=_cmd_ledger_replay)
234
+ get = lsub.add_parser("get", help="dump one entry by seq")
235
+ _add_ledger(get)
236
+ get.add_argument("seq", type=int)
237
+ get.set_defaults(func=_cmd_ledger_get)
238
+ summary = lsub.add_parser("summary", help="aggregate the ledger into a run summary")
239
+ _add_ledger(summary)
240
+ summary.add_argument("--json", action="store_true", help="emit the summary as JSON")
241
+ summary.set_defaults(func=_cmd_ledger_summary)
242
+ ledger.set_defaults(func=lambda a: _print_help_rc(ledger))
243
+
244
+ bench = sub.add_parser("bench", help="compare two ledgers (A/B) by their summaries")
245
+ bench.add_argument("a", help="ledger directory A")
246
+ bench.add_argument("b", help="ledger directory B")
247
+ bench.add_argument("--json", action="store_true", help="emit both summaries and the delta as JSON")
248
+ bench.set_defaults(func=_cmd_bench)
249
+
250
+ return parser
251
+
252
+
253
+ def main(argv=None) -> int:
254
+ parser = build_parser()
255
+ args = parser.parse_args(argv)
256
+ func = getattr(args, "func", None)
257
+ if func is None:
258
+ parser.print_help()
259
+ return 1
260
+ return func(args)
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())
forum/context.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class ContextProvider(Protocol):
7
+ """Supplies organized context for a request, before Forum plans or routes.
8
+
9
+ This is the seam to the "brain": a peer like the index flagship can implement
10
+ it (rendering its code-and-knowledge map to text), and Forum will witness the
11
+ exact context that shaped a plan. Keep it pure and offline; return "" when
12
+ there is nothing relevant. Forum never imports the provider, only this shape.
13
+ """
14
+
15
+ def context(self, request: str) -> str: ...
16
+
17
+
18
+ class NullContextProvider:
19
+ """The zero-dependency default: no external context. Forum stands alone."""
20
+
21
+ def context(self, request: str) -> str:
22
+ return ""
forum/control.py ADDED
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from forum.executor import Assignment, Executor
6
+ from forum.llm import ask_json
7
+ from forum.plan import Plan, Task
8
+ from forum.roster import Roster
9
+
10
+ _COORDINATOR_PROMPT = """You are a planner. Break the request into a minimal task DAG.
11
+ Available agents: <<AGENTS>>.
12
+ Return ONLY JSON of the form:
13
+ {"tasks": [{"id": "T1", "agent": "<one of the agents>", "instruction": "...", "depends_on": []}]}
14
+ Use depends_on to order tasks. Keep the plan small.
15
+
16
+ Request: <<REQUEST>>"""
17
+
18
+
19
+ class Coordinator:
20
+ """Turn a plain request into a validated task plan, using a model."""
21
+
22
+ async def plan(self, request: str, roster: Roster, executor: Executor, context: str = "") -> Plan:
23
+ names = [a.name for a in roster.agents]
24
+ prompt = _COORDINATOR_PROMPT.replace("<<AGENTS>>", ", ".join(names)).replace("<<REQUEST>>", request)
25
+ if context:
26
+ prompt = f"Context (organized knowledge to use):\n{context}\n\n" + prompt
27
+ data = await ask_json(executor, "coordinator", prompt)
28
+ if "tasks" not in data:
29
+ raise ValueError(f"coordinator response missing 'tasks'; got keys {list(data)}")
30
+ tasks = tuple(
31
+ Task(
32
+ str(t["id"]),
33
+ str(t["agent"]),
34
+ str(t["instruction"]),
35
+ tuple(str(d) for d in t.get("depends_on", [])),
36
+ )
37
+ for t in data["tasks"]
38
+ )
39
+ for t in tasks:
40
+ if t.agent not in names:
41
+ raise ValueError(f"coordinator chose unknown agent: {t.agent!r}")
42
+ plan = Plan(tasks)
43
+ plan.schedule() # raises on a cycle or an unknown dependency
44
+ return plan
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class Classification:
49
+ agent: str
50
+ confidence: float
51
+ reason: str
52
+
53
+
54
+ _CLASSIFIER_PROMPT = """Pick the single best agent for the task.
55
+ Agents: <<AGENTS>>.
56
+ Return ONLY JSON of the form:
57
+ {"agent": "<one of the agents>", "confidence": 0.0, "reason": "..."}
58
+
59
+ Task: <<TASK>>"""
60
+
61
+
62
+ class Classifier:
63
+ """Pick an agent for a single task when keyword routing cannot decide."""
64
+
65
+ async def classify(self, task: str, roster: Roster, executor: Executor) -> Classification:
66
+ names = [a.name for a in roster.agents]
67
+ prompt = _CLASSIFIER_PROMPT.replace("<<AGENTS>>", ", ".join(names)).replace("<<TASK>>", task)
68
+ data = await ask_json(executor, "classifier", prompt)
69
+ if "agent" not in data:
70
+ raise ValueError(f"classifier response missing 'agent'; got keys {list(data)}")
71
+ agent = str(data["agent"])
72
+ if agent not in names:
73
+ raise ValueError(f"classifier chose unknown agent: {agent!r}")
74
+ return Classification(agent, float(data.get("confidence", 0.0)), str(data.get("reason", "")))
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class Verdict:
79
+ ok: bool
80
+ score: float
81
+ reason: str
82
+
83
+
84
+ _VALIDATOR_PROMPT = """Judge whether the output satisfies the instruction.
85
+ Return ONLY JSON of the form:
86
+ {"ok": true, "score": 0.0, "reason": "..."}
87
+
88
+ Instruction: <<INSTRUCTION>>
89
+ Output: <<OUTPUT>>"""
90
+
91
+
92
+ class Validator:
93
+ """Judge an output against its instruction, using a model."""
94
+
95
+ async def validate(self, instruction: str, output: str, executor: Executor) -> Verdict:
96
+ prompt = _VALIDATOR_PROMPT.replace("<<INSTRUCTION>>", instruction).replace("<<OUTPUT>>", output)
97
+ data = await ask_json(executor, "validator", prompt)
98
+ if "ok" not in data:
99
+ raise ValueError(f"validator response missing 'ok'; got keys {list(data)}")
100
+ raw_ok = data["ok"]
101
+ ok = raw_ok if isinstance(raw_ok, bool) else str(raw_ok).strip().lower() not in ("false", "0", "no", "")
102
+ return Verdict(ok, float(data.get("score", 0.0)), str(data.get("reason", "")))
103
+
104
+
105
+ _SYNTHESIZER_PROMPT = """Combine the task results into one clear answer to the request.
106
+
107
+ Request: <<REQUEST>>
108
+ Results:
109
+ <<RESULTS>>
110
+
111
+ Write the final answer."""
112
+
113
+
114
+ class Synthesizer:
115
+ """Combine task results into one answer, using a model."""
116
+
117
+ async def synthesize(self, request: str, results: dict, executor: Executor) -> str:
118
+ lines = "\n".join(f"- {tid}: {r.output}" for tid, r in results.items())
119
+ prompt = _SYNTHESIZER_PROMPT.replace("<<REQUEST>>", request).replace("<<RESULTS>>", lines)
120
+ out = await executor.run(Assignment("control:synthesizer", "synthesizer", prompt))
121
+ return out.output.strip()