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.
- agent_augury/__init__.py +3 -0
- agent_augury/agent/__init__.py +1 -0
- agent_augury/agent/loop.py +241 -0
- agent_augury/agent/system_prompt.py +86 -0
- agent_augury/agent/tools.py +203 -0
- agent_augury/auth/__init__.py +6 -0
- agent_augury/auth/oauth.py +315 -0
- agent_augury/auth/token_store.py +125 -0
- agent_augury/backend/__init__.py +5 -0
- agent_augury/backend/base.py +59 -0
- agent_augury/backend/fake.py +28 -0
- agent_augury/backend/nous_portal.py +30 -0
- agent_augury/backend/nous_portal_oauth.py +273 -0
- agent_augury/backend/openai_compat.py +137 -0
- agent_augury/backends_factory.py +128 -0
- agent_augury/channel/__init__.py +1 -0
- agent_augury/channel/discord_bot.py +184 -0
- agent_augury/channel/discord_mirror.py +76 -0
- agent_augury/cli.py +353 -0
- agent_augury/config.py +128 -0
- agent_augury/model_config.py +81 -0
- agent_augury/model_listing.py +37 -0
- agent_augury/protocol/__init__.py +35 -0
- agent_augury/protocol/approval.py +133 -0
- agent_augury/protocol/collaboration.py +285 -0
- agent_augury/protocol/phases.py +73 -0
- agent_augury/server.py +366 -0
- agent_augury/session.py +455 -0
- agent_augury/wizard.py +493 -0
- agent_augury-0.3.0.dist-info/METADATA +216 -0
- agent_augury-0.3.0.dist-info/RECORD +35 -0
- agent_augury-0.3.0.dist-info/WHEEL +4 -0
- agent_augury-0.3.0.dist-info/entry_points.txt +2 -0
- agent_augury-0.3.0.dist-info/licenses/LICENSE +202 -0
- agent_augury-0.3.0.dist-info/licenses/NOTICE +24 -0
agent_augury/__init__.py
ADDED
|
@@ -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}"})
|