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 +3 -0
- forum/actor.py +43 -0
- forum/api_executor.py +86 -0
- forum/budget.py +18 -0
- forum/chat_executor.py +103 -0
- forum/cli.py +264 -0
- forum/context.py +22 -0
- forum/control.py +121 -0
- forum/daemon.py +186 -0
- forum/dispatch.py +65 -0
- forum/engine.py +300 -0
- forum/executor.py +75 -0
- forum/hashing.py +17 -0
- forum/http_surface.py +180 -0
- forum/intent.py +64 -0
- forum/ledger.py +225 -0
- forum/llm.py +25 -0
- forum/manifests/default-roster.toml +208 -0
- forum/mcp_surface.py +171 -0
- forum/message.py +41 -0
- forum/plan.py +41 -0
- forum/policy.py +18 -0
- forum/report.py +82 -0
- forum/roster.py +82 -0
- forum/routing.py +50 -0
- forum/storage.py +144 -0
- forum/supervisor.py +29 -0
- forum_engine-1.4.0.dist-info/METADATA +281 -0
- forum_engine-1.4.0.dist-info/RECORD +33 -0
- forum_engine-1.4.0.dist-info/WHEEL +5 -0
- forum_engine-1.4.0.dist-info/entry_points.txt +2 -0
- forum_engine-1.4.0.dist-info/licenses/LICENSE +78 -0
- forum_engine-1.4.0.dist-info/top_level.txt +1 -0
forum/__init__.py
ADDED
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()
|