agent-augury 0.3.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.
@@ -0,0 +1,3 @@
1
+ """agent-augury — model-agnostic passive awareness multi-agent runtime."""
2
+
3
+ __version__ = "0.2"
@@ -0,0 +1 @@
1
+ """Agent package: loop, tools, system prompt."""
@@ -0,0 +1,241 @@
1
+ """Agent loop: step() with inbox drain as single consumer (§3.5.2, §3.6).
2
+
3
+ Also resolves ``$thread:N`` placeholders against this agent's own
4
+ create_thread results, so scripted/real tool sequences can reference
5
+ threads created earlier in the same run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Callable
14
+
15
+ from ..backend.base import Completion, ModelBackend
16
+ from ..server import MessageServer
17
+ from .system_prompt import render_system_prompt
18
+ from .tools import ToolBox
19
+
20
+ Message = dict[str, Any]
21
+
22
+ _THREAD_REF = re.compile(r"^\$thread:(\d+)$")
23
+ _THREAD_BY_NAME = re.compile(r"^\$thread_by_name:(.+)$")
24
+
25
+
26
+ @dataclass
27
+ class StepResult:
28
+ """Outcome of one agent step."""
29
+
30
+ text: str | None = None
31
+ tool_calls: list[Any] = field(default_factory=list)
32
+ drained_count: int = 0
33
+ usage: dict[str, Any] | None = None
34
+
35
+
36
+ def format_radio_block(messages: list[Message]) -> str:
37
+ """§3.6 — drained messages merge into ONE user turn wrapped in [radio].
38
+
39
+ Message content passes through unchanged; prefixes like URGENT:/FYI:
40
+ travel inside the content itself.
41
+ """
42
+ lines = ["[radio]"]
43
+ lines.extend(f"from {m['author']}: {m['content']}".rstrip() for m in messages)
44
+ return "\n".join(lines)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class LocalTool:
49
+ """A non-server tool bound directly to the agent (e.g. search)."""
50
+
51
+ name: str
52
+ description: str
53
+ schema: dict[str, Any]
54
+ handler: Callable[[dict[str, Any]], Any] # sync or async → JSON-serializable
55
+
56
+
57
+ class AgentLoop:
58
+ """One agent's turn loop. The ONLY inbox consumer is step()."""
59
+
60
+ def __init__(
61
+ self,
62
+ agent_id: str,
63
+ server: MessageServer,
64
+ backend: ModelBackend,
65
+ system_prompt: str | None = None,
66
+ local_tools: list[LocalTool] | None = None,
67
+ on_tool_call: Callable[[str, str, dict[str, Any], Any], None] | None = None,
68
+ allowed_roots: list[str] | None = None,
69
+ ) -> None:
70
+ self.agent_id = agent_id
71
+ self.server = server
72
+ self.backend = backend
73
+ self.tools = ToolBox(server, allowed_roots=allowed_roots)
74
+ self.local_tools: dict[str, LocalTool] = {t.name: t for t in local_tools or []}
75
+ self.conversation: list[Message] = [
76
+ {
77
+ "role": "system",
78
+ "content": system_prompt or render_system_prompt(agent_id),
79
+ }
80
+ ]
81
+ self._custom_system_prompt = system_prompt is not None
82
+ # thread ids this agent created, in creation order ($thread:N source)
83
+ self.created_threads: list[str] = []
84
+ # gate-aware execution state (injected by Session each step)
85
+ self.gate_open: bool = True
86
+ self.gate_thread_id: str | None = None
87
+ # v0.2: current protocol phase (injected by Session each step)
88
+ self.current_phase: str = ""
89
+ # Real-time tool event callback (fires immediately on each tool execution)
90
+ self.on_tool_call = on_tool_call
91
+
92
+ # -- tool spec passthrough (mode-aware) ---------------------------------
93
+
94
+ @property
95
+ def tool_specs(self) -> list[dict[str, Any]]:
96
+ specs = self.tools.specs()
97
+ for tool in self.local_tools.values():
98
+ specs.append(
99
+ {
100
+ "name": tool.name,
101
+ "description": tool.description,
102
+ "schema": tool.schema,
103
+ }
104
+ )
105
+ return specs
106
+
107
+ def _update_phase_in_prompt(self) -> None:
108
+ """Update the system prompt to reflect the current phase."""
109
+ if self._custom_system_prompt:
110
+ return # user-supplied prompt — don't overwrite
111
+ if self.conversation and self.conversation[0]["role"] == "system":
112
+ self.conversation[0]["content"] = render_system_prompt(
113
+ self.agent_id, self.current_phase
114
+ )
115
+
116
+ async def step(self) -> StepResult:
117
+ """One model turn. Drains the inbox first; injects a [radio] user turn."""
118
+ # Update system prompt with current phase
119
+ self._update_phase_in_prompt()
120
+ drained = await self.server.drain_inbox(self.agent_id)
121
+
122
+ if drained:
123
+ self.conversation.append(
124
+ {"role": "user", "content": format_radio_block(drained)}
125
+ )
126
+
127
+ completion: Completion = await self.backend.complete(
128
+ self.conversation, self.tool_specs
129
+ )
130
+ assistant_msg: Message = {"role": "assistant", "content": completion.text or ""}
131
+ if completion.tool_calls:
132
+ assistant_msg["tool_calls"] = [
133
+ {
134
+ "id": call.id,
135
+ "type": "function",
136
+ "function": {
137
+ "name": call.name,
138
+ "arguments": json.dumps(call.arguments, ensure_ascii=False),
139
+ },
140
+ }
141
+ for call in completion.tool_calls
142
+ ]
143
+ self.conversation.append(assistant_msg)
144
+
145
+ tool_results: list[Message] = []
146
+ for call in completion.tool_calls:
147
+ args = self._resolve_refs(call.arguments)
148
+ try:
149
+ result = await self._execute_tool(call.name, args)
150
+ if call.name == "create_thread":
151
+ self.created_threads.append(json.loads(result)["thread_id"])
152
+ except Exception as exc: # noqa: BLE001 — surfaced to the model verbatim
153
+ result = json.dumps({"error": repr(exc)}, ensure_ascii=False)
154
+ # Fire real-time tool event callback immediately
155
+ if self.on_tool_call is not None:
156
+ self.on_tool_call(self.agent_id, call.name, call.arguments, result)
157
+ tool_results.append({
158
+ "role": "tool",
159
+ "tool_call_id": call.id,
160
+ "content": result,
161
+ })
162
+ if tool_results:
163
+ self.conversation.extend(tool_results)
164
+
165
+ return StepResult(
166
+ text=completion.text,
167
+ tool_calls=completion.tool_calls,
168
+ drained_count=len(drained),
169
+ usage=completion.usage,
170
+ )
171
+
172
+ async def _execute_tool(self, name: str, args: dict[str, Any]) -> str:
173
+ if name in self.local_tools:
174
+ tool = self.local_tools[name]
175
+ value = tool.handler(args)
176
+ if hasattr(value, "__await__"):
177
+ value = await value
178
+ return json.dumps(value, ensure_ascii=False, default=str)
179
+ # gate-aware execution: block work-share on non-gate threads while gate is closed
180
+ if name == "send_message" and not self.gate_open:
181
+ thread_id = args.get("thread")
182
+ if thread_id:
183
+ # If gate_thread_id is set, block any non-gate thread
184
+ if self.gate_thread_id is not None and thread_id != self.gate_thread_id:
185
+ return json.dumps(
186
+ {
187
+ "error": "gate_closed",
188
+ "message": (
189
+ f"Gate is CLOSED. Work-share on thread '{thread_id}' is blocked. "
190
+ f"Post APPROVE on the gate thread '{self.gate_thread_id}' to open the gate."
191
+ ),
192
+ },
193
+ ensure_ascii=False,
194
+ )
195
+ # If gate_thread_id is None (initial state, no gate bound yet),
196
+ # only READY messages are allowed (to finish P1).
197
+ # PROPOSE/APPROVE/REJECT are NOT allowed until a gate thread
198
+ # is explicitly bound.
199
+ if self.gate_thread_id is None:
200
+ content = args.get("content", "")
201
+ if content != "READY:":
202
+ return json.dumps(
203
+ {
204
+ "error": "gate_closed",
205
+ "message": (
206
+ f"Gate is CLOSED. Work-share on thread '{thread_id}' is blocked. "
207
+ f"Send READY: to finish P1 exploration first."
208
+ ),
209
+ },
210
+ ensure_ascii=False,
211
+ )
212
+ return await self.tools.execute(self.agent_id, name, args)
213
+
214
+ def _resolve_refs(self, args: dict[str, Any]) -> dict[str, Any]:
215
+ resolved: dict[str, Any] = {}
216
+ for key, value in args.items():
217
+ if isinstance(value, str):
218
+ m = _THREAD_BY_NAME.match(value)
219
+ if m:
220
+ resolved[key] = self._find_thread_by_name(m.group(1))
221
+ continue
222
+ m = _THREAD_REF.match(value)
223
+ if m:
224
+ idx = int(m.group(1))
225
+ try:
226
+ resolved[key] = self.created_threads[idx]
227
+ continue
228
+ except IndexError:
229
+ raise ValueError(
230
+ f"$thread:{idx} has no matching create_thread result "
231
+ f"(agent {self.agent_id} created {len(self.created_threads)})"
232
+ ) from None
233
+ resolved[key] = value
234
+ return resolved
235
+
236
+ def _find_thread_by_name(self, name: str) -> str:
237
+ """Resolve a thread id from the SSOT by name (what read_resource offers)."""
238
+ for thread in self.server.snapshot()["threads"]:
239
+ if thread["name"] == name:
240
+ return thread["thread_id"]
241
+ raise ValueError(f"no thread named {name!r} exists yet")
@@ -0,0 +1,86 @@
1
+ """Model-agnostic communication-rules prompt template (§3.5.1, §2.4).
2
+
3
+ v0.2: includes phase-aware instructions for P1~P5 collaboration protocol.
4
+ """
5
+
6
+ SYSTEM_PROMPT_TEMPLATE = """\
7
+ You are `{agent_id}`, one agent in a multi-agent team sharing radio threads.
8
+
9
+ Communication rules:
10
+ - You may open threads (`create_thread`) and post messages (`send_message`).
11
+ - `send_message` is fire-and-forget. It returns immediately — never wait after sending.
12
+ - To address teammates use mentions: `"mentions": ["agent-2"]` in send_message.
13
+ In message text, write mentions as @agent-2 (surface syntax).
14
+ - An EMPTY mentions list is a broadcast to everyone in the thread except you.
15
+ - Prefix your messages when useful:
16
+ - "FYI: ..." or "(FYI) ..." — reference only, no reply expected.
17
+ - "URGENT: ..." or "(URGENT) ..." — affects what the receiver is doing right
18
+ now; they must handle it before continuing their current approach.
19
+ - Incoming teammate messages appear automatically as a single [radio] block in
20
+ a user turn. Read it at your next step boundary and keep working.
21
+ - `read_resource` dumps full thread/message state. Use it only when you need
22
+ history or recovery — it is never pushed to you automatically.
23
+
24
+ Filesystem tools (for exploring code and files):
25
+ - `read_file(path)` — read a file's content. Use this to examine source code,
26
+ configuration files, or any text file you need to understand.
27
+ - `list_directory(path)` — list files and directories. Use this to explore
28
+ project structure before reading specific files.
29
+ - `write_file(path, content)` — write content to a file. Use this to create
30
+ reports, notes, or modified files.
31
+
32
+ When investigating a codebase, start with `list_directory` to understand the
33
+ structure, then use `read_file` on relevant files. Always read files before
34
+ making claims about their contents.
35
+
36
+ {phase_instructions}
37
+ """
38
+
39
+ # Phase-specific instruction templates
40
+ _PHASE_INSTRUCTIONS = {
41
+ "P1_EXPLORE": """\
42
+ Current phase: **P1 EXPLORE**
43
+ - Independently explore the task and gather information.
44
+ - Formulate sub-questions and draft initial findings.
45
+ - Do NOT send messages to teammates yet — exploration is silent.
46
+ - When you are done exploring, send ``READY:`` to signal completion.
47
+ Only ``READY:`` is recognized; ``READYFOO`` or similar is ignored.
48
+ P1 finishes automatically once ALL participants have sent ``READY:``.
49
+ Note: READY: is the ONLY message allowed during P1 — all other
50
+ send_message calls will be blocked by the gate.""",
51
+ "P2_SPLIT": """\
52
+ Current phase: **P2 SPLIT**
53
+ - Pool your discoveries with teammates on the plan thread.
54
+ - Negotiate a split of sub-questions among agents.
55
+ - Propose a division with `PROPOSE:` and approve with `APPROVE:`.
56
+ - The phase advances only when ALL agents approve.""",
57
+ "P3_EXECUTE": """\
58
+ Current phase: **P3 EXECUTE**
59
+ - Execute your assigned share of the work.
60
+ - Post work logs and intermediate findings to the work thread immediately.
61
+ - Share contradictions, obstacles, or abandoned approaches.""",
62
+ "P4_REVIEW": """\
63
+ Current phase: **P4 REVIEW**
64
+ - Broadcast your results with supporting evidence on the results thread.
65
+ - Review teammates' submissions for factual conflicts, insufficient evidence,
66
+ or omissions. Flag issues explicitly.""",
67
+ "P5_SUBMIT": """\
68
+ Current phase: **P5 SUBMIT**
69
+ - The assembler composes the final answer from approved results.
70
+ - Broadcast the final answer for review.
71
+ - Approve with `APPROVE:` to submit, or request changes with `REJECT:`.""",
72
+ }
73
+
74
+
75
+ def render_system_prompt(agent_id: str, phase: str = "") -> str:
76
+ """Render the system prompt for an agent.
77
+
78
+ Args:
79
+ agent_id: The agent's identifier.
80
+ phase: Current protocol phase (e.g. "P2_SPLIT"). If empty, no phase
81
+ instructions are included.
82
+ """
83
+ phase_instructions = _PHASE_INSTRUCTIONS.get(phase, "")
84
+ return SYSTEM_PROMPT_TEMPLATE.format(
85
+ agent_id=agent_id, phase_instructions=phase_instructions
86
+ )
@@ -0,0 +1,203 @@
1
+ """Agent-facing tools backed by the internal message server (§3.5.1).
2
+
3
+ Exposes: create_thread, send_message, read_resource, read_file,
4
+ list_directory, write_file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from ..server import MessageServer
13
+
14
+
15
+ def _json(result: Any) -> str:
16
+ return json.dumps(result, ensure_ascii=False)
17
+
18
+
19
+ class ToolBox:
20
+ """Binds server operations into model-callable tools."""
21
+
22
+ def __init__(self, server: MessageServer, allowed_roots: list[str] | None = None) -> None:
23
+ self.server = server
24
+ self.allowed_roots = allowed_roots
25
+
26
+ # -- tool specs ----------------------------------------------------------
27
+
28
+ def specs(self) -> list[dict[str, Any]]:
29
+ """JSON-schema tool specs."""
30
+ return [
31
+ {
32
+ "name": "create_thread",
33
+ "description": "Open a named conversation thread with the given participants and return its id.",
34
+ "schema": {
35
+ "type": "object",
36
+ "properties": {
37
+ "name": {"type": "string"},
38
+ "participants": {
39
+ "type": "array",
40
+ "items": {"type": "string"},
41
+ },
42
+ },
43
+ "required": ["name", "participants"],
44
+ },
45
+ },
46
+ {
47
+ "name": "send_message",
48
+ "description": (
49
+ "Post a message to a thread. Fire-and-forget: returns immediately. "
50
+ "Empty mentions broadcasts to the thread's participants (except you). "
51
+ 'Prefix content with "(FYI)" or "(URGENT)" when appropriate.'
52
+ ),
53
+ "schema": {
54
+ "type": "object",
55
+ "properties": {
56
+ "thread": {"type": "string", "description": "thread id"},
57
+ "content": {"type": "string"},
58
+ "mentions": {
59
+ "type": "array",
60
+ "items": {"type": "string"},
61
+ "description": "agent ids to address; empty = broadcast",
62
+ },
63
+ },
64
+ "required": ["thread", "content"],
65
+ },
66
+ },
67
+ {
68
+ "name": "read_resource",
69
+ "description": "Explicit full state dump of threads/messages for recovery or aggregation. Never injected automatically.",
70
+ "schema": {"type": "object", "properties": {}},
71
+ },
72
+ {
73
+ "name": "read_file",
74
+ "description": "Read a file's content from the filesystem. Returns the file content as text.",
75
+ "schema": {
76
+ "type": "object",
77
+ "properties": {
78
+ "path": {"type": "string", "description": "file path to read"},
79
+ },
80
+ "required": ["path"],
81
+ },
82
+ },
83
+ {
84
+ "name": "list_directory",
85
+ "description": "List contents of a directory. Returns entries with name, is_dir, and size.",
86
+ "schema": {
87
+ "type": "object",
88
+ "properties": {
89
+ "path": {"type": "string", "description": "directory path to list", "default": "."},
90
+ },
91
+ },
92
+ },
93
+ {
94
+ "name": "write_file",
95
+ "description": "Write content to a file on the filesystem. Creates parent directories as needed.",
96
+ "schema": {
97
+ "type": "object",
98
+ "properties": {
99
+ "path": {"type": "string", "description": "file path to write"},
100
+ "content": {"type": "string", "description": "content to write"},
101
+ },
102
+ "required": ["path", "content"],
103
+ },
104
+ },
105
+ ]
106
+
107
+ # -- execution -----------------------------------------------------------
108
+
109
+ async def execute(self, agent_id: str, name: str, args: dict[str, Any]) -> str:
110
+ if name == "create_thread":
111
+ tid = await self.server.create_thread(
112
+ args["name"], participants=list(args["participants"])
113
+ )
114
+ return _json({"thread_id": tid})
115
+ if name == "send_message":
116
+ mid = await self.server.send_message(
117
+ args["thread"],
118
+ author=agent_id,
119
+ content=args["content"],
120
+ mentions=list(args.get("mentions") or []),
121
+ )
122
+ return _json({"message_id": mid, "status": "sent"})
123
+ if name == "read_resource":
124
+ snap = self.server.snapshot()
125
+ self.server._emit_event({
126
+ "type": "read_resource",
127
+ "agent_id": agent_id,
128
+ "threads": len(snap["threads"]),
129
+ "messages": len(snap["messages"]),
130
+ "timestamp": int(__import__("time").time()),
131
+ })
132
+ return _json(snap)
133
+ if name == "read_file":
134
+ return await self._read_file(args)
135
+ if name == "list_directory":
136
+ return await self._list_directory(args)
137
+ if name == "write_file":
138
+ return await self._write_file(args)
139
+ raise ValueError(f"unknown tool: {name}")
140
+
141
+ # -- filesystem tools -----------------------------------------------------
142
+
143
+ async def _read_file(self, args: dict[str, Any]) -> str:
144
+ """Read a file's content."""
145
+ import os
146
+ path = args.get("path", "")
147
+ if not path:
148
+ return _json({"error": "path is required"})
149
+ # Security: resolve to absolute path and check it's within allowed roots
150
+ abs_path = os.path.abspath(path)
151
+ allowed_roots = self.allowed_roots
152
+ if allowed_roots:
153
+ if not any(abs_path.startswith(os.path.abspath(root)) for root in allowed_roots):
154
+ return _json({"error": f"path outside allowed roots: {path}"})
155
+ try:
156
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
157
+ content = f.read()
158
+ return _json({"path": abs_path, "content": content, "size": len(content)})
159
+ except Exception as exc:
160
+ return _json({"error": f"failed to read {path}: {exc}"})
161
+
162
+ async def _list_directory(self, args: dict[str, Any]) -> str:
163
+ """List directory contents."""
164
+ import os
165
+ path = args.get("path", ".")
166
+ abs_path = os.path.abspath(path)
167
+ allowed_roots = self.allowed_roots
168
+ if allowed_roots:
169
+ if not any(abs_path.startswith(os.path.abspath(root)) for root in allowed_roots):
170
+ return _json({"error": f"path outside allowed roots: {path}"})
171
+ try:
172
+ entries = []
173
+ for entry in os.listdir(abs_path):
174
+ full = os.path.join(abs_path, entry)
175
+ stat = os.stat(full)
176
+ entries.append({
177
+ "name": entry,
178
+ "is_dir": os.path.isdir(full),
179
+ "size": stat.st_size,
180
+ })
181
+ return _json({"path": abs_path, "entries": entries})
182
+ except Exception as exc:
183
+ return _json({"error": f"failed to list {path}: {exc}"})
184
+
185
+ async def _write_file(self, args: dict[str, Any]) -> str:
186
+ """Write content to a file."""
187
+ import os
188
+ path = args.get("path", "")
189
+ content = args.get("content", "")
190
+ if not path:
191
+ return _json({"error": "path is required"})
192
+ abs_path = os.path.abspath(path)
193
+ allowed_roots = self.allowed_roots
194
+ if allowed_roots:
195
+ if not any(abs_path.startswith(os.path.abspath(root)) for root in allowed_roots):
196
+ return _json({"error": f"path outside allowed roots: {path}"})
197
+ try:
198
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
199
+ with open(abs_path, "w", encoding="utf-8") as f:
200
+ f.write(content)
201
+ return _json({"path": abs_path, "size": len(content), "status": "written"})
202
+ except Exception as exc:
203
+ return _json({"error": f"failed to write {path}: {exc}"})
@@ -0,0 +1,6 @@
1
+ """OAuth authentication for agent-augury.
2
+
3
+ Provides browser-based OAuth flows (device code, PKCE) and token persistence.
4
+ Reuses Hermes Agent's proven OAuth patterns while staying independent.
5
+ """
6
+