manyagent 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.
- manyagent/__init__.py +120 -0
- manyagent/__init__.pyi +25 -0
- manyagent/_handlers.py +489 -0
- manyagent/_hook.py +114 -0
- manyagent/_installer.py +1031 -0
- manyagent/_mcp.py +302 -0
- manyagent/adapters/__init__.py +32 -0
- manyagent/adapters/base.py +273 -0
- manyagent/adapters/builtin/__init__.py +161 -0
- manyagent/adapters/builtin/claude.py +96 -0
- manyagent/adapters/builtin/codex.py +71 -0
- manyagent/adapters/builtin/gemini.py +76 -0
- manyagent/adapters/builtin/qwen.py +28 -0
- manyagent/adapters/miners/__init__.py +14 -0
- manyagent/adapters/miners/claude.py +247 -0
- manyagent/adapters/registry.py +78 -0
- manyagent/adapters/skills/__init__.py +21 -0
- manyagent/adapters/skills/claude.py +296 -0
- manyagent/adapters/skills/codex.py +223 -0
- manyagent/adapters/skills/gemini.py +308 -0
- manyagent/bank/__init__.py +43 -0
- manyagent/bank/base.py +74 -0
- manyagent/bank/fake.py +243 -0
- manyagent/bank/retry.py +48 -0
- manyagent/bank/supabase_bank.py +239 -0
- manyagent/capture/__init__.py +84 -0
- manyagent/capture/bound.py +114 -0
- manyagent/capture/conformance.py +40 -0
- manyagent/capture/models.py +63 -0
- manyagent/capture/scrub.py +64 -0
- manyagent/cli.py +1191 -0
- manyagent/core/__init__.py +28 -0
- manyagent/core/collection.py +56 -0
- manyagent/core/models.py +232 -0
- manyagent/distill/__init__.py +53 -0
- manyagent/distill/curator.py +173 -0
- manyagent/distill/parse.py +178 -0
- manyagent/distill/prompts.py +149 -0
- manyagent/distill/resolve.py +157 -0
- manyagent/distill/schema.py +53 -0
- manyagent/distill/server.py +54 -0
- manyagent/distill/weighting.py +85 -0
- manyagent/forum/__init__.py +40 -0
- manyagent/forum/anti_meta.py +181 -0
- manyagent/forum/discuss.py +69 -0
- manyagent/forum/parser.py +117 -0
- manyagent/forum/prompt.py +107 -0
- manyagent/forum/schema.py +53 -0
- manyagent/preflight.py +132 -0
- manyagent/py.typed +0 -0
- manyagent/testing.py +613 -0
- manyagent/utils/__init__.py +32 -0
- manyagent/utils/config.py +107 -0
- manyagent/utils/log.py +28 -0
- manyagent/utils/messages.py +125 -0
- manyagent/utils/provider.py +276 -0
- manyagent/utils/sid.py +71 -0
- manyagent/utils/ui.py +313 -0
- manyagent/web/__init__.py +13 -0
- manyagent/web/api.py +535 -0
- manyagent/web/server.py +126 -0
- manyagent-0.1.0.dist-info/METADATA +286 -0
- manyagent-0.1.0.dist-info/RECORD +66 -0
- manyagent-0.1.0.dist-info/WHEEL +4 -0
- manyagent-0.1.0.dist-info/entry_points.txt +2 -0
- manyagent-0.1.0.dist-info/licenses/LICENSE +21 -0
manyagent/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""ManyAgent — wrap installed coding-agent CLIs; curate cross-session knowledge.
|
|
2
|
+
|
|
3
|
+
Distribution name: ``manyagent``. Import name: ``manyagent``. Console script: ``manyagent``.
|
|
4
|
+
Identity is fixed here and in ``pyproject.toml`` and is never re-derived as a
|
|
5
|
+
string elsewhere (datasmith identity rule; Package Structure & Workflow).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import os
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import dotenv
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup_environment() -> None:
|
|
20
|
+
"""Load environment variables from ``~/.manyagent/env`` **and** ``./manyagent.env``.
|
|
21
|
+
|
|
22
|
+
Two layers, lowest precedence first so the cwd file wins on overlap:
|
|
23
|
+
|
|
24
|
+
1. ``~/.manyagent/env`` — the user-level fallback (M11). Loaded so an MCP server
|
|
25
|
+
launched by Claude Code / Codex / Gemini *outside* a project directory
|
|
26
|
+
still finds the Bank credentials installed once by ``manyagent start``.
|
|
27
|
+
2. ``./manyagent.env`` — the project-scoped overrides (M0).
|
|
28
|
+
|
|
29
|
+
``dotenv.load_dotenv`` does not overwrite already-set process vars, so the
|
|
30
|
+
real env always wins over file values. CLI flags still win over both.
|
|
31
|
+
"""
|
|
32
|
+
user_home = os.environ.get("MANYAGENT_HOME") or os.path.expanduser("~/.manyagent")
|
|
33
|
+
user_env = os.path.join(user_home, "env")
|
|
34
|
+
if os.path.exists(user_env):
|
|
35
|
+
dotenv.load_dotenv(user_env)
|
|
36
|
+
if os.path.exists("manyagent.env"):
|
|
37
|
+
dotenv.load_dotenv("manyagent.env")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
setup_environment()
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# PEP 562 lazy loading
|
|
44
|
+
#
|
|
45
|
+
# Package-level lazy import of known, static submodules: explicit (everything
|
|
46
|
+
# enumerated below), type-checker-visible, and the pattern datasmith kept. This
|
|
47
|
+
# is NOT the per-instance __getattr__ dispatch forbidden by Design Principles §4
|
|
48
|
+
# (reconciled in Package Structure & Workflow). Public symbols are added to
|
|
49
|
+
# _LAZY_IMPORTS by the milestone that introduces them.
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
_SUBMODULES: set[str] = {
|
|
53
|
+
"utils",
|
|
54
|
+
"core",
|
|
55
|
+
"bank",
|
|
56
|
+
"capture",
|
|
57
|
+
"adapters",
|
|
58
|
+
"forum",
|
|
59
|
+
"distill",
|
|
60
|
+
"testing",
|
|
61
|
+
"web",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
|
65
|
+
# --- core (M3): the flat value-object surface the Overview REPL uses ---
|
|
66
|
+
"Session": ("manyagent.core", "Session"),
|
|
67
|
+
"Goal": ("manyagent.core", "Goal"),
|
|
68
|
+
"Agent": ("manyagent.core", "Agent"),
|
|
69
|
+
"Packet": ("manyagent.core", "Packet"),
|
|
70
|
+
"KnowledgePacket": ("manyagent.core", "KnowledgePacket"),
|
|
71
|
+
"Collection": ("manyagent.core", "Collection"),
|
|
72
|
+
# --- capture (M4): the CanonicalTrace contract adapter authors conform to ---
|
|
73
|
+
"CanonicalTrace": ("manyagent.capture", "CanonicalTrace"),
|
|
74
|
+
"TraceEvent": ("manyagent.capture", "TraceEvent"),
|
|
75
|
+
"ScrubReport": ("manyagent.capture", "ScrubReport"),
|
|
76
|
+
# --- adapters (M5): the extension-point contract (builtins stay nested) ---
|
|
77
|
+
"Adapter": ("manyagent.adapters", "Adapter"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"__version__",
|
|
82
|
+
"setup_environment",
|
|
83
|
+
*sorted(_SUBMODULES),
|
|
84
|
+
*sorted(_LAZY_IMPORTS),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def __getattr__(name: str) -> object:
|
|
89
|
+
if name in _SUBMODULES:
|
|
90
|
+
mod = importlib.import_module(f"manyagent.{name}")
|
|
91
|
+
globals()[name] = mod
|
|
92
|
+
return mod
|
|
93
|
+
|
|
94
|
+
if name in _LAZY_IMPORTS:
|
|
95
|
+
module_path, attr_name = _LAZY_IMPORTS[name]
|
|
96
|
+
mod = importlib.import_module(module_path)
|
|
97
|
+
val = getattr(mod, attr_name)
|
|
98
|
+
globals()[name] = val
|
|
99
|
+
return val
|
|
100
|
+
|
|
101
|
+
raise AttributeError(f"module 'manyagent' has no attribute {name!r}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def __dir__() -> list[str]:
|
|
105
|
+
return __all__
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Static type-checking imports (never executed at runtime)
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
if TYPE_CHECKING:
|
|
112
|
+
from manyagent import adapters as adapters
|
|
113
|
+
from manyagent import bank as bank
|
|
114
|
+
from manyagent import capture as capture
|
|
115
|
+
from manyagent import core as core
|
|
116
|
+
from manyagent import distill as distill
|
|
117
|
+
from manyagent import forum as forum
|
|
118
|
+
from manyagent import testing as testing
|
|
119
|
+
from manyagent import utils as utils
|
|
120
|
+
from manyagent import web as web
|
manyagent/__init__.pyi
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Type stub for manyagent — keeps mypy happy with PEP 562 lazy loading."""
|
|
2
|
+
|
|
3
|
+
from manyagent import adapters as adapters
|
|
4
|
+
from manyagent import bank as bank
|
|
5
|
+
from manyagent import capture as capture
|
|
6
|
+
from manyagent import core as core
|
|
7
|
+
from manyagent import distill as distill
|
|
8
|
+
from manyagent import forum as forum
|
|
9
|
+
from manyagent import testing as testing
|
|
10
|
+
from manyagent import utils as utils
|
|
11
|
+
from manyagent import web as web
|
|
12
|
+
from manyagent.adapters import Adapter as Adapter
|
|
13
|
+
from manyagent.capture import CanonicalTrace as CanonicalTrace
|
|
14
|
+
from manyagent.capture import ScrubReport as ScrubReport
|
|
15
|
+
from manyagent.capture import TraceEvent as TraceEvent
|
|
16
|
+
from manyagent.core import Agent as Agent
|
|
17
|
+
from manyagent.core import Collection as Collection
|
|
18
|
+
from manyagent.core import Goal as Goal
|
|
19
|
+
from manyagent.core import KnowledgePacket as KnowledgePacket
|
|
20
|
+
from manyagent.core import Packet as Packet
|
|
21
|
+
from manyagent.core import Session as Session
|
|
22
|
+
|
|
23
|
+
__version__: str
|
|
24
|
+
|
|
25
|
+
def setup_environment() -> None: ...
|
manyagent/_handlers.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""manyagent._handlers — the four knowledge-loop verbs as plain async functions.
|
|
2
|
+
|
|
3
|
+
These used to live in ``manyagent.cli`` as ``_do_self_distill`` / ``_do_discuss`` /
|
|
4
|
+
``_do_cross_distill`` / ``_do_inject`` driven by argparse Namespaces. M11.4
|
|
5
|
+
hoists them out: the in-agent skills surface (manyagent._mcp) is the user-facing
|
|
6
|
+
path; ``manyagent.cli`` keeps only the *session lifecycle* verbs (``start`` /
|
|
7
|
+
``register`` / ``<name>`` / ``end`` / ``status`` / ``uninstall``).
|
|
8
|
+
|
|
9
|
+
Signatures are **plain kwargs** (no argparse Namespace coupling). Tests,
|
|
10
|
+
``scripts/simulate_story.py``, and any future programmatic caller use these
|
|
11
|
+
functions directly. The MCP server (``manyagent._mcp``) is a different surface —
|
|
12
|
+
its tools call into ``manyagent.forum`` / ``manyagent.distill`` / ``manyagent.bank`` for the
|
|
13
|
+
same effects, but the *gating* (C1 accept) lives in the agent UI's
|
|
14
|
+
permission prompt, not in this module's ``ask_yn``/``ask_rating`` calls.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from manyagent.bank import Bank
|
|
25
|
+
from manyagent.utils import config, messages, sid, ui
|
|
26
|
+
|
|
27
|
+
# These three helpers stay in manyagent.cli (CLI-state and prompt helpers); import
|
|
28
|
+
# them lazily inside handlers to avoid circular import at module load.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def parse_post_safely(record: dict[str, Any], *, bank: Bank) -> tuple[bool, dict[str, Any] | str]:
|
|
32
|
+
from manyagent.forum import parse_post
|
|
33
|
+
|
|
34
|
+
return await parse_post(record, bank=bank)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _agent_json(adapter: Any, prompt: str) -> Any:
|
|
38
|
+
"""Shell the adapter's own headless model for the structured post. The
|
|
39
|
+
model's ``.complete`` is synchronous (a CLI shell-out) so it is run via
|
|
40
|
+
``asyncio.to_thread`` — calling it directly would block the loop (the M5
|
|
41
|
+
async-wrapper hazard)."""
|
|
42
|
+
model = adapter.distill_model()
|
|
43
|
+
if model is None:
|
|
44
|
+
raise SystemExit(f"{adapter.name}: no headless model available; cannot generate a post")
|
|
45
|
+
fn = model.complete
|
|
46
|
+
raw = await fn(prompt) if asyncio.iscoroutinefunction(fn) else await asyncio.to_thread(fn, prompt)
|
|
47
|
+
raw = str(raw).strip()
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(raw)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
a, b = raw.find("{"), raw.rfind("}")
|
|
52
|
+
if a != -1 and b > a:
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(raw[a : b + 1])
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
return None
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _head_tail(text: str, budget: int) -> str:
|
|
61
|
+
"""Byte-bounded head+tail with one explicit elision marker (the
|
|
62
|
+
``manyagent.capture.bound`` discipline, applied to prompt context)."""
|
|
63
|
+
raw = text.encode("utf-8")
|
|
64
|
+
if len(raw) <= budget:
|
|
65
|
+
return text
|
|
66
|
+
half = max(1, (budget - 96) // 2) # reserve room for the marker itself
|
|
67
|
+
head = raw[:half].decode("utf-8", "ignore")
|
|
68
|
+
tail = raw[-half:].decode("utf-8", "ignore")
|
|
69
|
+
elided = len(raw) - half * 2
|
|
70
|
+
return f"{head}\n[... {elided} bytes elided for context budget {budget} ...]\n{tail}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_harness_scaffold(text: str) -> bool:
|
|
74
|
+
"""Harness plumbing masquerading as dialogue: slash-command envelopes and
|
|
75
|
+
injected skill bodies are Claude-Code/manyagent scaffolding, not the user's or
|
|
76
|
+
agent's words. Left in the distill context they dominate a short session
|
|
77
|
+
and the distiller reflects on manyagent itself (observed 2026-06-11: over half
|
|
78
|
+
the rendered trace was the /self-distill skill body)."""
|
|
79
|
+
head = text.lstrip()
|
|
80
|
+
return head.startswith((
|
|
81
|
+
"<command-message>",
|
|
82
|
+
"<command-name>",
|
|
83
|
+
"<local-command-stdout>",
|
|
84
|
+
"Base directory for this skill:",
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _transcript_text(path: str) -> str:
|
|
89
|
+
"""Flatten one harness transcript (jsonl) into ``role: text`` dialogue
|
|
90
|
+
lines. Defensive: a missing/unreadable/odd-shaped file yields ``""``."""
|
|
91
|
+
from manyagent.adapters.builtin import _jsonl, _msg_text
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
raw = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
95
|
+
except OSError:
|
|
96
|
+
return ""
|
|
97
|
+
lines: list[str] = []
|
|
98
|
+
for obj in _jsonl(raw):
|
|
99
|
+
t = str(obj.get("type", ""))
|
|
100
|
+
if t in ("user", "assistant"):
|
|
101
|
+
txt = _msg_text(obj.get("message", ""))
|
|
102
|
+
if txt and not _is_harness_scaffold(txt):
|
|
103
|
+
lines.append(f"{'user' if t == 'user' else 'agent'}: {txt}")
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _rendition_text(body: Any) -> str:
|
|
108
|
+
"""Flatten a mined ``harness`` rendition (manyagent.adapters.miners) into
|
|
109
|
+
dialogue lines, TOOL TURNS INCLUDED — the transcript-only flatten drops
|
|
110
|
+
tool_use/tool_result blocks, so a session whose story is its tool activity
|
|
111
|
+
(observed 2026-06-11: an MCP flail invisible to the distiller) distills
|
|
112
|
+
into noise. Defensive: any odd shape yields ``""``."""
|
|
113
|
+
if isinstance(body, str):
|
|
114
|
+
try:
|
|
115
|
+
body = json.loads(body)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return ""
|
|
118
|
+
if not isinstance(body, dict):
|
|
119
|
+
return ""
|
|
120
|
+
lines: list[str] = []
|
|
121
|
+
for seg in body.get("segments") or []:
|
|
122
|
+
if not isinstance(seg, dict):
|
|
123
|
+
continue
|
|
124
|
+
for turn in seg.get("turns") or []:
|
|
125
|
+
if not isinstance(turn, dict):
|
|
126
|
+
continue
|
|
127
|
+
role = str(turn.get("role") or "")
|
|
128
|
+
text = str(turn.get("text") or "")
|
|
129
|
+
tool = turn.get("tool")
|
|
130
|
+
if role == "tool" and isinstance(tool, dict):
|
|
131
|
+
lines.append(f"tool: {tool.get('name', '?')} {tool.get('input_preview') or ''}".rstrip())
|
|
132
|
+
elif text.strip() and not _is_harness_scaffold(text):
|
|
133
|
+
lines.append(f"{'agent' if role == 'assistant' else 'user'}: {text}")
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _bound_transcripts_text(sid_: str, *, since: float | None, budget: int) -> str:
|
|
138
|
+
"""Flatten the run's bound transcript(s): newest binding first, deduped by
|
|
139
|
+
path, stopping once ``budget`` is reached; chronological order restored."""
|
|
140
|
+
from manyagent.cli import _harness_bindings
|
|
141
|
+
|
|
142
|
+
parts: list[str] = []
|
|
143
|
+
seen: set[str] = set()
|
|
144
|
+
for rec in reversed(_harness_bindings(sid_, since=since or 0.0)): # newest first
|
|
145
|
+
tp = str(rec.get("transcript_path") or "")
|
|
146
|
+
if not tp or tp in seen:
|
|
147
|
+
continue
|
|
148
|
+
seen.add(tp)
|
|
149
|
+
part = _transcript_text(tp)
|
|
150
|
+
if part:
|
|
151
|
+
parts.append(part)
|
|
152
|
+
if sum(len(p.encode("utf-8")) for p in parts) >= budget:
|
|
153
|
+
break
|
|
154
|
+
parts.reverse() # back to chronological order
|
|
155
|
+
return "\n\n".join(parts)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _raw_packet_text(bank: Bank, raw_id: str) -> str:
|
|
159
|
+
"""The scrubbed ``raw`` packet body's event text, defensively parsed."""
|
|
160
|
+
trace_row = await bank.get_trace(raw_id)
|
|
161
|
+
try:
|
|
162
|
+
body = json.loads(str((trace_row or {}).get("body") or "{}"))
|
|
163
|
+
events = body.get("events", []) if isinstance(body, dict) else []
|
|
164
|
+
return "\n".join(str(e.get("text", "")) for e in events if isinstance(e, dict))
|
|
165
|
+
except (ValueError, TypeError):
|
|
166
|
+
return ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _trace_context(sid_: str, *, bank: Bank, since: float | None = None) -> str | None:
|
|
170
|
+
"""The session content rendered into a post prompt (2026-06-10): once the
|
|
171
|
+
wrapped agent exits, the conversation lives only in the bound harness
|
|
172
|
+
transcript(s) (``$MANYAGENT_HOME/bindings/<sid>.jsonl``, appended by
|
|
173
|
+
``manyagent._hook``) and the captured ``raw`` packet — NOT in any model's head,
|
|
174
|
+
so the headless ``distill_model()`` shell-out must be handed it.
|
|
175
|
+
|
|
176
|
+
The mined ``harness`` rendition of the newest ``raw`` packet wins
|
|
177
|
+
(2026-06-11): it is the only source that carries TOOL TURNS (the
|
|
178
|
+
transcript flatten keeps text blocks only), it is run-scoped by the miner
|
|
179
|
+
(``MineContext.window`` starts at the same run-start clock ``since``
|
|
180
|
+
carries), and it is already scrubbed + capped. Bound transcripts are the
|
|
181
|
+
fallback (newest binding first, deduped by path, ``since``-scoped when
|
|
182
|
+
several wrapped runs share the session); the scrubbed ``raw`` packet body
|
|
183
|
+
is last. ``None`` when the session left no trace at all — the prompt then
|
|
184
|
+
carries no trace section."""
|
|
185
|
+
from manyagent.capture import CanonicalTrace, TraceEvent, scrub
|
|
186
|
+
|
|
187
|
+
budget = config.resolve("MANYAGENT_DISTILL_CONTEXT_MAX_BYTES", config.MANYAGENT_DISTILL_CONTEXT_MAX_BYTES, cast=int)
|
|
188
|
+
|
|
189
|
+
raws = await bank.list_packets(session_id=sid_, type="raw")
|
|
190
|
+
text = ""
|
|
191
|
+
if raws: # the mined conversation view, tool turns included
|
|
192
|
+
rend = await bank.get_rendition(str(raws[-1]["id"]), "harness")
|
|
193
|
+
if rend:
|
|
194
|
+
text = _rendition_text(rend.get("body"))
|
|
195
|
+
if not text.strip(): # no rendition — flatten the bound transcript(s)
|
|
196
|
+
text = _bound_transcripts_text(sid_, since=since, budget=budget)
|
|
197
|
+
if not text.strip() and raws: # last resort — the scrubbed raw packet body
|
|
198
|
+
text = await _raw_packet_text(bank, str(raws[-1]["id"]))
|
|
199
|
+
if not text.strip():
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
# Transcripts carry full tool outputs and are NOT pre-scrubbed (the raw
|
|
203
|
+
# packet is, but re-scrubbing is free) — never put a secret in a prompt.
|
|
204
|
+
scrubbed, _ = scrub(
|
|
205
|
+
CanonicalTrace(
|
|
206
|
+
session_id=sid_,
|
|
207
|
+
agent_id="",
|
|
208
|
+
adapter="",
|
|
209
|
+
events=[TraceEvent(0.0, "system", text)],
|
|
210
|
+
source_fidelity="pty",
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
return _head_tail(scrubbed.events[0].text, budget)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _adapter_cls(name: str) -> Any:
|
|
217
|
+
from manyagent.adapters import resolve as resolve_adapter
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
return resolve_adapter(name)
|
|
221
|
+
except Exception as exc: # registry: local → builtin → hub; not found
|
|
222
|
+
raise SystemExit(f"unknown adapter {name!r}: {exc}") from exc
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _adapter_for(name: str, *, session_id: str, agent_id: str) -> Any:
|
|
226
|
+
return _adapter_cls(name)(session_id=session_id, agent_id=agent_id)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_adapter(name: str) -> None:
|
|
230
|
+
"""Gate before minting a new agent row: ``name`` must resolve in the
|
|
231
|
+
registry AND its wrapped binary must be on PATH — otherwise ``register``
|
|
232
|
+
happily persists agents for CLIs that can never run (decision
|
|
233
|
+
2026-06-10). ``manyagent.testing.Simulation`` patches this seam alongside
|
|
234
|
+
``_adapter_for``."""
|
|
235
|
+
cls = _adapter_cls(name)
|
|
236
|
+
if not cls.is_available():
|
|
237
|
+
raise SystemExit(
|
|
238
|
+
f"adapter {name!r} resolved but its CLI {cls.binary or name!r} is not on PATH — install it first"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _resolve_agent(sid_: str, name: str, *, bank: Bank) -> str:
|
|
243
|
+
"""Latest registered agent for ``name`` in the session, else auto-register
|
|
244
|
+
one (``register`` is explicit but optional — manyagent.cli.md)."""
|
|
245
|
+
agents = [a for a in await bank.list_agents(sid_) if a.get("adapter") == name]
|
|
246
|
+
if agents:
|
|
247
|
+
return str(agents[-1]["id"])
|
|
248
|
+
_validate_adapter(name)
|
|
249
|
+
seq = await bank.next_agent_seq(sid_)
|
|
250
|
+
agent_id = f"{sid_}/agent-{seq:03d}-{name}"
|
|
251
|
+
await bank.put_agent(agent_id, session_id=sid_, adapter=name, seq=seq)
|
|
252
|
+
return agent_id
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def _emit_post(
|
|
256
|
+
*,
|
|
257
|
+
kind: str,
|
|
258
|
+
sid_: str,
|
|
259
|
+
agent_id: str,
|
|
260
|
+
goal: str | None,
|
|
261
|
+
structured: Any,
|
|
262
|
+
reply_to: str | None,
|
|
263
|
+
stance: str | None,
|
|
264
|
+
bank: Bank,
|
|
265
|
+
io: tuple[Any, Any],
|
|
266
|
+
ask_star: bool,
|
|
267
|
+
) -> int:
|
|
268
|
+
"""Shared single-gate commit flow for a ``reflection``/``reply`` post:
|
|
269
|
+
one allowance prompt carries both the commit decision and the ★ (user
|
|
270
|
+
decision 2026-06-10 — no separate accept/reject question).
|
|
271
|
+
**C1**: a declined or parser-refused post is NOT persisted (the record
|
|
272
|
+
never carries ``preference``); the caller re-prompts."""
|
|
273
|
+
from manyagent.cli import _noninteractive, ask_allow, ask_commit
|
|
274
|
+
|
|
275
|
+
record: dict[str, Any] = {
|
|
276
|
+
"id": f"{sid_}/{sid.new().replace('-', '').lower()[:8]}",
|
|
277
|
+
"session_id": sid_,
|
|
278
|
+
"type": "post",
|
|
279
|
+
"agent_id": agent_id,
|
|
280
|
+
"kind": kind,
|
|
281
|
+
"goal": goal,
|
|
282
|
+
"structured": structured,
|
|
283
|
+
}
|
|
284
|
+
if kind == "reply":
|
|
285
|
+
record["reply_to"] = reply_to
|
|
286
|
+
record["stance"] = stance
|
|
287
|
+
|
|
288
|
+
ok, res = await parse_post_safely(record, bank=bank)
|
|
289
|
+
if not ok or not isinstance(res, dict): # narrows res → dict for the rest
|
|
290
|
+
io[1](messages.POST_REJECTED_BY_DISCIPLINE.format(reason=res))
|
|
291
|
+
return 1 # caller re-prompts (C1: nothing persisted)
|
|
292
|
+
|
|
293
|
+
body = res.get("structured", {})
|
|
294
|
+
preview = ui.render_post(body, kind=kind)
|
|
295
|
+
io[1](preview)
|
|
296
|
+
# Truncated preview ⇒ the untruncated rendering is one `d` away at the gate.
|
|
297
|
+
expanded = ui.render_post(body, kind=kind, full=True)
|
|
298
|
+
detail = expanded if expanded != preview else None
|
|
299
|
+
# The commit gate is NOT deny-by-default: the mechanical parser already
|
|
300
|
+
# gated quality, so an unattended (MANYAGENT_NONINTERACTIVE) run auto-commits —
|
|
301
|
+
# the open-ended loop must keep running with no human present. Deny-by-
|
|
302
|
+
# default (Open-Q §B5) is scoped to /inject + destructive confirms, not to
|
|
303
|
+
# the agent's own parser-validated post (manyagent.cli.md: noninteractive →
|
|
304
|
+
# unrated + no inject; it does not gate /self-distill).
|
|
305
|
+
if ask_star:
|
|
306
|
+
proposed = body.get("confidence")
|
|
307
|
+
prop = {"high": 5, "medium": 3, "low": 2}.get(str(proposed), 3)
|
|
308
|
+
accepted, rating = ask_commit(
|
|
309
|
+
prop, input_fn=io[0], output_fn=io[1], noninteractive=_noninteractive(), detail=detail
|
|
310
|
+
)
|
|
311
|
+
if accepted and rating is not None:
|
|
312
|
+
res["rating"] = rating
|
|
313
|
+
else:
|
|
314
|
+
accepted = _noninteractive() or ask_allow(
|
|
315
|
+
messages.REPLY_COMMIT_OFFER, input_fn=io[0], output_fn=io[1], noninteractive=False, detail=detail
|
|
316
|
+
)
|
|
317
|
+
if not accepted:
|
|
318
|
+
io[1](messages.POST_DISCARDED)
|
|
319
|
+
return 1 # C1: NOT stored, no preference key
|
|
320
|
+
|
|
321
|
+
res.pop("preference", None) # C1 belt-and-suspenders: a post never carries it
|
|
322
|
+
await bank.put_packet(res)
|
|
323
|
+
io[1](messages.POST_STORED.format(post_id=res["id"]))
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# --------------------------------------------------------------------------- #
|
|
328
|
+
# the four knowledge-loop verbs (kwargs API — no argparse Namespace coupling)
|
|
329
|
+
# --------------------------------------------------------------------------- #
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def do_self_distill(
|
|
333
|
+
*,
|
|
334
|
+
adapter: str,
|
|
335
|
+
guidance: str | None = None,
|
|
336
|
+
session: str | None = None,
|
|
337
|
+
since: float | None = None,
|
|
338
|
+
bank: Bank,
|
|
339
|
+
io: tuple[Any, Any],
|
|
340
|
+
) -> int:
|
|
341
|
+
from manyagent.cli import _resolve_sid
|
|
342
|
+
from manyagent.forum import render_post_prompt
|
|
343
|
+
|
|
344
|
+
sid_ = _resolve_sid(session)
|
|
345
|
+
session_row = await bank.get_session(sid_)
|
|
346
|
+
goal = (session_row or {}).get("goal")
|
|
347
|
+
agent_id = await _resolve_agent(sid_, adapter, bank=bank)
|
|
348
|
+
adapter_obj = _adapter_for(adapter, session_id=sid_, agent_id=agent_id)
|
|
349
|
+
trace_ctx = await _trace_context(sid_, bank=bank, since=since)
|
|
350
|
+
prompt = render_post_prompt(kind="reflection", goal=goal, guidance=guidance, trace_context=trace_ctx)
|
|
351
|
+
structured = await _agent_json(adapter_obj, prompt)
|
|
352
|
+
if structured is None:
|
|
353
|
+
io[1](messages.NO_PARSEABLE_POST)
|
|
354
|
+
return 1
|
|
355
|
+
return await _emit_post(
|
|
356
|
+
kind="reflection",
|
|
357
|
+
sid_=sid_,
|
|
358
|
+
agent_id=agent_id,
|
|
359
|
+
goal=goal,
|
|
360
|
+
structured=structured,
|
|
361
|
+
reply_to=None,
|
|
362
|
+
stance=None,
|
|
363
|
+
bank=bank,
|
|
364
|
+
io=io,
|
|
365
|
+
ask_star=True,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def do_discuss(
|
|
370
|
+
*,
|
|
371
|
+
adapter: str,
|
|
372
|
+
stance: str = "synthesize",
|
|
373
|
+
packet: str | None = None,
|
|
374
|
+
session: str | None = None,
|
|
375
|
+
bank: Bank,
|
|
376
|
+
io: tuple[Any, Any],
|
|
377
|
+
) -> int:
|
|
378
|
+
from manyagent.cli import _resolve_sid
|
|
379
|
+
from manyagent.forum import enforce_retrieved_before_reply, render_post_prompt, retrieve
|
|
380
|
+
|
|
381
|
+
sid_ = _resolve_sid(session)
|
|
382
|
+
session_row = await bank.get_session(sid_)
|
|
383
|
+
goal = (session_row or {}).get("goal")
|
|
384
|
+
agent_id = await _resolve_agent(sid_, adapter, bank=bank)
|
|
385
|
+
adapter_obj = _adapter_for(adapter, session_id=sid_, agent_id=agent_id)
|
|
386
|
+
|
|
387
|
+
ranked = await retrieve(sid_, agent_id=agent_id, goal=goal, bank=bank) # retrieval-before-post
|
|
388
|
+
if not ranked:
|
|
389
|
+
io[1](messages.DISCUSS_NO_POSTS)
|
|
390
|
+
return 1
|
|
391
|
+
reply_to = packet.lstrip("@") if packet else str(ranked[0]["id"])
|
|
392
|
+
reason = enforce_retrieved_before_reply(sid_, agent_id, reply_to)
|
|
393
|
+
if reason is not None:
|
|
394
|
+
io[1](messages.DISCUSS_REFUSED.format(reason=reason))
|
|
395
|
+
return 1 # not persisted (C1)
|
|
396
|
+
trace_ctx = await _trace_context(sid_, bank=bank)
|
|
397
|
+
prompt = render_post_prompt(kind="reply", goal=goal, prior_posts=ranked, trace_context=trace_ctx)
|
|
398
|
+
structured = await _agent_json(adapter_obj, prompt)
|
|
399
|
+
if structured is None:
|
|
400
|
+
io[1](messages.NO_PARSEABLE_REPLY)
|
|
401
|
+
return 1
|
|
402
|
+
return await _emit_post(
|
|
403
|
+
kind="reply",
|
|
404
|
+
sid_=sid_,
|
|
405
|
+
agent_id=agent_id,
|
|
406
|
+
goal=goal,
|
|
407
|
+
structured=structured,
|
|
408
|
+
reply_to=reply_to,
|
|
409
|
+
stance=stance,
|
|
410
|
+
bank=bank,
|
|
411
|
+
io=io,
|
|
412
|
+
ask_star=False,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def do_cross_distill(
|
|
417
|
+
*,
|
|
418
|
+
server: bool = False,
|
|
419
|
+
session: str | None = None,
|
|
420
|
+
bank: Bank,
|
|
421
|
+
io: tuple[Any, Any],
|
|
422
|
+
) -> int:
|
|
423
|
+
from manyagent.cli import _resolve_sid
|
|
424
|
+
from manyagent.distill import CurationError, NoPostsError, curate
|
|
425
|
+
|
|
426
|
+
sid_ = _resolve_sid(session)
|
|
427
|
+
session_row = await bank.get_session(sid_)
|
|
428
|
+
goal = (session_row or {}).get("goal")
|
|
429
|
+
if goal == config.resolve("MANYAGENT_DEFAULT_GOAL", config.MANYAGENT_DEFAULT_GOAL):
|
|
430
|
+
goal = None # the default bucket is the catch-all, not a curated goal
|
|
431
|
+
scope = "per_goal" if goal else "cross_goal"
|
|
432
|
+
mode = "server" if server else None
|
|
433
|
+
try:
|
|
434
|
+
pkt = await curate(scope=scope, goal=goal, bank=bank, mode=mode)
|
|
435
|
+
except NoPostsError as exc:
|
|
436
|
+
io[1](str(exc)) # exact "Run /self-distill first!"
|
|
437
|
+
return 1
|
|
438
|
+
except CurationError as exc:
|
|
439
|
+
io[1](messages.CURATION_FAILED.format(reason=exc))
|
|
440
|
+
return 1
|
|
441
|
+
io[1](messages.CURATED_BUNDLE.format(scope=pkt.scope, bundle_id=pkt.id, curator=pkt.curator))
|
|
442
|
+
return 0
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def do_inject(
|
|
446
|
+
*,
|
|
447
|
+
packet: str | None = None,
|
|
448
|
+
session: str | None = None,
|
|
449
|
+
bank: Bank,
|
|
450
|
+
io: tuple[Any, Any],
|
|
451
|
+
) -> int:
|
|
452
|
+
from manyagent.cli import _noninteractive, _resolve_sid, ask_allow, preview_tokens
|
|
453
|
+
|
|
454
|
+
sid_ = _resolve_sid(session)
|
|
455
|
+
if packet:
|
|
456
|
+
pid = packet.lstrip("@")
|
|
457
|
+
else:
|
|
458
|
+
distills = await bank.list_packets(type="distill", include_quarantined=False)
|
|
459
|
+
if not distills:
|
|
460
|
+
io[1](messages.INJECT_NOTHING)
|
|
461
|
+
return 1
|
|
462
|
+
pid = str(distills[-1]["id"])
|
|
463
|
+
rec = await bank.get_packet(pid)
|
|
464
|
+
if rec is None:
|
|
465
|
+
io[1](messages.INJECT_UNKNOWN_PACKET.format(packet_id=pid))
|
|
466
|
+
return 1
|
|
467
|
+
if rec.get("quarantined"):
|
|
468
|
+
io[1](messages.INJECT_QUARANTINED.format(packet_id=pid))
|
|
469
|
+
return 1 # refused BEFORE preview
|
|
470
|
+
bundle_text = json.dumps(rec.get("bundle", {}), indent=2)
|
|
471
|
+
io[1](messages.INJECT_PREVIEW_HEADER)
|
|
472
|
+
io[1](
|
|
473
|
+
preview_tokens(
|
|
474
|
+
bundle_text,
|
|
475
|
+
head=config.MANYAGENT_INJECT_PREVIEW_HEAD_TOKENS,
|
|
476
|
+
tail=config.MANYAGENT_INJECT_PREVIEW_TAIL_TOKENS,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
if not ask_allow(
|
|
480
|
+
messages.INJECT_OFFER.format(packet_id=pid, session_id=sid_),
|
|
481
|
+
input_fn=io[0],
|
|
482
|
+
output_fn=io[1],
|
|
483
|
+
noninteractive=_noninteractive(),
|
|
484
|
+
):
|
|
485
|
+
io[1](messages.INJECT_DECLINED)
|
|
486
|
+
return 1
|
|
487
|
+
await bank.record_injection(pid, sid_)
|
|
488
|
+
io[1](messages.INJECT_RECORDED.format(packet_id=pid, session_id=sid_))
|
|
489
|
+
return 0
|