bone-agent 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +19 -0
- package/bin/bone-linux-x64 +0 -0
- package/bin/bone.js +57 -0
- package/defaults/lua/commands/compact.lua +360 -0
- package/defaults/lua/commands/customize.lua +143 -0
- package/defaults/lua/commands/memory.lua +164 -0
- package/defaults/lua/commands/review.lua +31 -0
- package/defaults/lua/commands/usage.lua +118 -0
- package/defaults/lua/tools/ask_user.lua +306 -0
- package/defaults/lua/tools/cron.lua +253 -0
- package/defaults/lua/tools/subagent.lua +350 -0
- package/defaults/lua/tools/task_list.lua +189 -0
- package/defaults/lua/tools/web_search.lua +41 -0
- package/install.js +43 -0
- package/package.json +46 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
local PYTHON_SCRIPT = [=[
|
|
2
|
+
import base64, json, os, re, shlex, subprocess, sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
def env(name, default=""):
|
|
6
|
+
return os.environ.get(name, default)
|
|
7
|
+
|
|
8
|
+
def bone_dir():
|
|
9
|
+
xdg = env("XDG_CONFIG_HOME")
|
|
10
|
+
if xdg: return Path(xdg) / "bone-rust"
|
|
11
|
+
home = env("HOME") or env("USERPROFILE")
|
|
12
|
+
if home: return Path(home) / ".bone-rust"
|
|
13
|
+
return Path(".bone-rust").resolve()
|
|
14
|
+
|
|
15
|
+
def find_bone():
|
|
16
|
+
explicit = env("BONE_BIN")
|
|
17
|
+
if explicit and os.access(explicit, os.X_OK):
|
|
18
|
+
return str(Path(explicit).resolve())
|
|
19
|
+
for part in env("PATH").split(os.pathsep):
|
|
20
|
+
candidate = Path(part) / "bone"
|
|
21
|
+
if os.access(candidate, os.X_OK):
|
|
22
|
+
return str(candidate.resolve())
|
|
23
|
+
print("bone binary not found. Set BONE_BIN=/path/to/bone", file=sys.stderr)
|
|
24
|
+
sys.exit(127)
|
|
25
|
+
|
|
26
|
+
def validate_name(name):
|
|
27
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", name or ""):
|
|
28
|
+
fail("job name must contain only letters, numbers, '-' and '_'")
|
|
29
|
+
|
|
30
|
+
def parse_time(value):
|
|
31
|
+
m = re.fullmatch(r"(\d{1,2}):(\d{2})", value or "")
|
|
32
|
+
if not m: fail("time must be HH:MM")
|
|
33
|
+
hour, minute = int(m.group(1)), int(m.group(2))
|
|
34
|
+
if hour > 23 or minute > 59: fail("time must be between 00:00 and 23:59")
|
|
35
|
+
return hour, minute
|
|
36
|
+
|
|
37
|
+
def validate_approval(value):
|
|
38
|
+
if value not in ("read_only", "danger"):
|
|
39
|
+
fail("approval must be read_only or danger")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
def fail(message, code=2):
|
|
43
|
+
print(message, file=sys.stderr)
|
|
44
|
+
sys.exit(code)
|
|
45
|
+
|
|
46
|
+
def cron_missing():
|
|
47
|
+
fail("crontab not found. Install cronie or cron.", 127)
|
|
48
|
+
|
|
49
|
+
def current_crontab():
|
|
50
|
+
try:
|
|
51
|
+
p = subprocess.run(["crontab", "-l"], text=True, capture_output=True)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
cron_missing()
|
|
54
|
+
if p.returncode == 0: return p.stdout
|
|
55
|
+
if "no crontab" in p.stderr.lower(): return ""
|
|
56
|
+
fail(p.stderr.strip() or f"crontab -l exited with {p.returncode}", p.returncode)
|
|
57
|
+
|
|
58
|
+
def write_crontab(content):
|
|
59
|
+
try:
|
|
60
|
+
p = subprocess.run(["crontab", "-"], input=content, text=True, capture_output=True)
|
|
61
|
+
except FileNotFoundError:
|
|
62
|
+
cron_missing()
|
|
63
|
+
if p.returncode != 0:
|
|
64
|
+
fail(p.stderr.strip() or f"crontab exited with {p.returncode}", p.returncode)
|
|
65
|
+
|
|
66
|
+
def encode_metadata(job):
|
|
67
|
+
raw = json.dumps(job, separators=(",", ":")).encode()
|
|
68
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
69
|
+
|
|
70
|
+
def decode_metadata(value):
|
|
71
|
+
try:
|
|
72
|
+
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
|
73
|
+
return json.loads(base64.urlsafe_b64decode(padded.encode()))
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def parse_cron_line(line):
|
|
78
|
+
marker = "# BONE:"
|
|
79
|
+
if marker not in line: return None
|
|
80
|
+
body, encoded = line.rsplit(marker, 1)
|
|
81
|
+
fields = body.split()
|
|
82
|
+
if len(fields) < 5 or fields[2:5] != ["*", "*", "*"]: return None
|
|
83
|
+
try:
|
|
84
|
+
minute, hour = int(fields[0]), int(fields[1])
|
|
85
|
+
except ValueError: return None
|
|
86
|
+
meta = decode_metadata(encoded.strip())
|
|
87
|
+
if not meta:
|
|
88
|
+
name = encoded.strip()
|
|
89
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", name): return None
|
|
90
|
+
meta = {"name": name, "approval": "", "cwd": "", "prompt": "", "log_path": ""}
|
|
91
|
+
meta["minute"] = minute
|
|
92
|
+
meta["hour"] = hour
|
|
93
|
+
return meta
|
|
94
|
+
|
|
95
|
+
def build_cron_line(job):
|
|
96
|
+
args = [job["bone_bin"], "run", "--approval", job["approval"]]
|
|
97
|
+
args.extend(["--prompt", job["prompt"]])
|
|
98
|
+
command = "cd " + shlex.quote(job["cwd"]) + " && " + " ".join(shlex.quote(a) for a in args)
|
|
99
|
+
command += " >> " + shlex.quote(job["log_path"]) + " 2>&1"
|
|
100
|
+
meta = {k: job[k] for k in ("name", "approval", "cwd", "prompt", "log_path")}
|
|
101
|
+
return f'{job["minute"]} {job["hour"]} * * * {command} # BONE:{encode_metadata(meta)}'
|
|
102
|
+
|
|
103
|
+
def list_jobs():
|
|
104
|
+
jobs = [j for j in (parse_cron_line(line) for line in current_crontab().splitlines()) if j]
|
|
105
|
+
if not jobs:
|
|
106
|
+
print("No bone cron jobs.")
|
|
107
|
+
return
|
|
108
|
+
print("NAME\tTIME\tAPPROVAL\tCWD\tPROMPT")
|
|
109
|
+
for j in jobs:
|
|
110
|
+
print(f'{j.get("name", "")}\t{j["hour"]:02d}:{j["minute"]:02d}\t{j.get("approval", "")}\t{j.get("cwd", "")}\t{j.get("prompt", "")}')
|
|
111
|
+
|
|
112
|
+
def add_job():
|
|
113
|
+
name, time, prompt = env("TOOL_NAME"), env("TOOL_TIME"), env("TOOL_PROMPT")
|
|
114
|
+
approval = validate_approval(env("TOOL_APPROVAL", "read_only") or "read_only")
|
|
115
|
+
if not name or not time or not prompt:
|
|
116
|
+
fail("Usage: cron add requires name, time, and prompt.")
|
|
117
|
+
validate_name(name)
|
|
118
|
+
hour, minute = parse_time(time)
|
|
119
|
+
cwd = str(Path(env("TOOL_CWD") or os.getcwd()).resolve())
|
|
120
|
+
log_dir = bone_dir() / "runs"
|
|
121
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
job = {"name": name, "hour": hour, "minute": minute, "approval": approval,
|
|
123
|
+
"cwd": cwd, "prompt": prompt, "log_path": str(log_dir / f"{name}.log"),
|
|
124
|
+
"bone_bin": find_bone()}
|
|
125
|
+
existing = current_crontab().splitlines()
|
|
126
|
+
kept = []
|
|
127
|
+
for line in existing:
|
|
128
|
+
parsed = parse_cron_line(line)
|
|
129
|
+
legacy_tag = line.rstrip().endswith(f"# BONE:{name}")
|
|
130
|
+
if (parsed and parsed.get("name") == name) or legacy_tag: continue
|
|
131
|
+
kept.append(line)
|
|
132
|
+
kept.append(build_cron_line(job))
|
|
133
|
+
write_crontab("\n".join(kept) + "\n")
|
|
134
|
+
print(f"Added cron job {name}.")
|
|
135
|
+
|
|
136
|
+
def remove_job():
|
|
137
|
+
name = env("TOOL_NAME")
|
|
138
|
+
if not name: fail("Usage: cron remove requires name.")
|
|
139
|
+
validate_name(name)
|
|
140
|
+
removed = False
|
|
141
|
+
kept = []
|
|
142
|
+
for line in current_crontab().splitlines():
|
|
143
|
+
parsed = parse_cron_line(line)
|
|
144
|
+
legacy_tag = line.rstrip().endswith(f"# BONE:{name}")
|
|
145
|
+
if (parsed and parsed.get("name") == name) or legacy_tag: removed = True
|
|
146
|
+
else: kept.append(line)
|
|
147
|
+
write_crontab(("\n".join(kept) + "\n") if kept else "")
|
|
148
|
+
print(f"Removed cron job {name}." if removed else f"No cron job named {name}.")
|
|
149
|
+
|
|
150
|
+
def show_logs():
|
|
151
|
+
name = env("TOOL_NAME")
|
|
152
|
+
if not name: fail("Usage: cron logs requires name.")
|
|
153
|
+
validate_name(name)
|
|
154
|
+
path = bone_dir() / "runs" / f"{name}.log"
|
|
155
|
+
try: lines = path.read_text().splitlines()
|
|
156
|
+
except OSError as e: fail(f"failed to read {path}: {e}", 1)
|
|
157
|
+
tail = env("TOOL_TAIL")
|
|
158
|
+
if tail:
|
|
159
|
+
try: n = int(tail)
|
|
160
|
+
except ValueError: fail("tail must be a number")
|
|
161
|
+
lines = lines[-n:]
|
|
162
|
+
print("\n".join(lines))
|
|
163
|
+
|
|
164
|
+
def help_text():
|
|
165
|
+
print("""Manage Bone scheduled jobs.
|
|
166
|
+
|
|
167
|
+
Examples:
|
|
168
|
+
cron(action=list)
|
|
169
|
+
cron(action=add, name=daily-clean, time=09:00, approval=danger, prompt=/clean src/main.rs)
|
|
170
|
+
cron(action=remove, name=daily-clean)
|
|
171
|
+
cron(action=logs, name=daily-clean, tail=100)""")
|
|
172
|
+
|
|
173
|
+
action = env("TOOL_ACTION")
|
|
174
|
+
if action == "list": list_jobs()
|
|
175
|
+
elif action == "add": add_job()
|
|
176
|
+
elif action in ("remove", "rm"): remove_job()
|
|
177
|
+
elif action == "logs": show_logs()
|
|
178
|
+
elif action in ("help", "--help", "-h", ""): help_text()
|
|
179
|
+
else: fail(f"Unknown cron action: {action}")
|
|
180
|
+
]=]
|
|
181
|
+
|
|
182
|
+
local function execute(params, ctx)
|
|
183
|
+
local action = params.action or ""
|
|
184
|
+
local name = params.name or ""
|
|
185
|
+
local time = params.time or ""
|
|
186
|
+
local approval = params.approval or "read_only"
|
|
187
|
+
local prompt = params.prompt or ""
|
|
188
|
+
local cwd = params.cwd or ""
|
|
189
|
+
local tail = params.tail or ""
|
|
190
|
+
|
|
191
|
+
-- Build export commands for TOOL_* variables
|
|
192
|
+
local exports = {}
|
|
193
|
+
table.insert(exports, 'export TOOL_ACTION="' .. action:gsub('"', '\\"') .. '"')
|
|
194
|
+
if name ~= "" then table.insert(exports, 'export TOOL_NAME="' .. name:gsub('"', '\\"') .. '"') end
|
|
195
|
+
if time ~= "" then table.insert(exports, 'export TOOL_TIME="' .. time:gsub('"', '\\"') .. '"') end
|
|
196
|
+
if approval ~= "" then table.insert(exports, 'export TOOL_APPROVAL="' .. approval:gsub('"', '\\"') .. '"') end
|
|
197
|
+
if prompt ~= "" then table.insert(exports, 'export TOOL_PROMPT="' .. prompt:gsub('"', '\\"') .. '"') end
|
|
198
|
+
if cwd ~= "" then table.insert(exports, 'export TOOL_CWD="' .. cwd:gsub('"', '\\"') .. '"') end
|
|
199
|
+
if tail ~= "" then table.insert(exports, 'export TOOL_TAIL="' .. tail:gsub('"', '\\"') .. '"') end
|
|
200
|
+
|
|
201
|
+
local cmd = table.concat(exports, "; ")
|
|
202
|
+
cmd = cmd .. "; uv run --no-project --no-sync -- python3 <<'PYEOF'\n"
|
|
203
|
+
cmd = cmd .. PYTHON_SCRIPT
|
|
204
|
+
cmd = cmd .. "\nPYEOF"
|
|
205
|
+
|
|
206
|
+
local result = ctx.shell(cmd, { timeout_ms = 300000 })
|
|
207
|
+
if result.stderr and #result.stderr > 0 then
|
|
208
|
+
return "ERROR: " .. result.stderr
|
|
209
|
+
end
|
|
210
|
+
return result.stdout or ""
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
bone.register_tool({
|
|
214
|
+
name = "cron",
|
|
215
|
+
description = "Manage Bone scheduled jobs for the user. Use this when the user asks to schedule, list, remove, or inspect recurring Bone tasks. Fully implemented as a custom tool; supports daily HH:MM schedules.",
|
|
216
|
+
parameters = {
|
|
217
|
+
type = "object",
|
|
218
|
+
properties = {
|
|
219
|
+
action = {
|
|
220
|
+
type = "string",
|
|
221
|
+
description = "Action: add, list, remove, logs, or help.",
|
|
222
|
+
},
|
|
223
|
+
name = {
|
|
224
|
+
type = "string",
|
|
225
|
+
description = "Job name for add/remove/logs. Use letters, numbers, '-' or '_'.",
|
|
226
|
+
},
|
|
227
|
+
time = {
|
|
228
|
+
type = "string",
|
|
229
|
+
description = "Daily run time in HH:MM 24-hour local time, required for add.",
|
|
230
|
+
},
|
|
231
|
+
approval = {
|
|
232
|
+
type = "string",
|
|
233
|
+
description = "Approval mode for add: read_only or danger. Defaults to read_only.",
|
|
234
|
+
},
|
|
235
|
+
prompt = {
|
|
236
|
+
type = "string",
|
|
237
|
+
description = "Prompt or command invocation for add.",
|
|
238
|
+
},
|
|
239
|
+
cwd = {
|
|
240
|
+
type = "string",
|
|
241
|
+
description = "Working directory for add. Defaults to current directory.",
|
|
242
|
+
},
|
|
243
|
+
tail = {
|
|
244
|
+
type = "number",
|
|
245
|
+
description = "Number of log lines for logs.",
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
required = { "action" },
|
|
249
|
+
additionalProperties = false,
|
|
250
|
+
},
|
|
251
|
+
safety = "danger",
|
|
252
|
+
execute = execute,
|
|
253
|
+
})
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
-- Sub-agent tool: discovery, dispatch, and status reporting.
|
|
2
|
+
--
|
|
3
|
+
-- Only active when sub-agents are registered via bone.register_subagent in
|
|
4
|
+
-- init.lua. When no agents are registered, this file is a no-op (zero
|
|
5
|
+
-- overhead) — the tool is never created.
|
|
6
|
+
--
|
|
7
|
+
-- The live status pane is rendered natively in Rust (src/ui/subagent_pane.rs)
|
|
8
|
+
-- so it stays responsive even while this tool blocks the Lua VM.
|
|
9
|
+
|
|
10
|
+
-- cjson is a global injected by Rust (encode/decode via serde_json)
|
|
11
|
+
|
|
12
|
+
-- ---------------------------------------------------------------------------
|
|
13
|
+
-- Early exits
|
|
14
|
+
-- ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
-- Sub-agents must not spawn nested sub-agents: never register the tool
|
|
17
|
+
-- inside a sub-agent VM.
|
|
18
|
+
if bone.agent_depth and bone.agent_depth > 0 then
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local subagents = bone._subagents
|
|
23
|
+
if not subagents or #subagents == 0 then
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
-- Headless mode (CLI): background job auto-injection is unavailable, so
|
|
28
|
+
-- dispatch must always block for results.
|
|
29
|
+
local headless = bone.headless and true or false
|
|
30
|
+
|
|
31
|
+
-- ---------------------------------------------------------------------------
|
|
32
|
+
-- Helpers
|
|
33
|
+
-- ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
--- Numeric part of a "job-N" id (0 when malformed).
|
|
36
|
+
local function job_id_number(id)
|
|
37
|
+
return tonumber((id or ""):match("(%d+)$")) or 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
--- Build a human-readable status string for a single job.
|
|
41
|
+
local function job_status(job)
|
|
42
|
+
if not job then
|
|
43
|
+
return "○ idle"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if job.status == "running" then
|
|
47
|
+
local elapsed = os.time() - job.started_at
|
|
48
|
+
local task = job.task or ""
|
|
49
|
+
if #task > 40 then
|
|
50
|
+
task = task:sub(1, 37) .. "..."
|
|
51
|
+
end
|
|
52
|
+
local sent = job.token_sent or 0
|
|
53
|
+
local received = job.token_received or 0
|
|
54
|
+
return string.format("◑ running %s (%ds) %s/%s in/out", task, elapsed, sent, received)
|
|
55
|
+
end
|
|
56
|
+
-- done → treat as idle
|
|
57
|
+
if job.status == "done" then
|
|
58
|
+
local sent = job.token_sent or 0
|
|
59
|
+
local received = job.token_received or 0
|
|
60
|
+
if sent > 0 or received > 0 then
|
|
61
|
+
return string.format("○ idle (%s/%s in/out)", sent, received)
|
|
62
|
+
end
|
|
63
|
+
return "○ idle"
|
|
64
|
+
end
|
|
65
|
+
-- error
|
|
66
|
+
return "✗ error"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
--- Build a status summary string for one agent (latest job by id).
|
|
70
|
+
local function agent_status(agent, jobs)
|
|
71
|
+
local latest = nil
|
|
72
|
+
for _, j in ipairs(jobs) do
|
|
73
|
+
if j.agent == agent.name then
|
|
74
|
+
if not latest or job_id_number(j.id) > job_id_number(latest.id) then
|
|
75
|
+
latest = j
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
return job_status(latest)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
-- ---------------------------------------------------------------------------
|
|
83
|
+
-- Build dynamic tool description
|
|
84
|
+
-- ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
local function build_description()
|
|
87
|
+
local parts = {
|
|
88
|
+
"Delegate tasks to registered sub-agents. Each agent runs in its own isolated context and only sees the task text you give it — write self-contained tasks (include file paths, goals, constraints, and the expected output format).",
|
|
89
|
+
"",
|
|
90
|
+
"Registered agents:",
|
|
91
|
+
}
|
|
92
|
+
for _, agent in ipairs(subagents) do
|
|
93
|
+
local extras = {}
|
|
94
|
+
if agent.approval then
|
|
95
|
+
extras[#extras + 1] = "approval: " .. agent.approval
|
|
96
|
+
end
|
|
97
|
+
local suffix = #extras > 0 and (" [" .. table.concat(extras, ", ") .. "]") or ""
|
|
98
|
+
parts[#parts + 1] = string.format(" - %s: %s%s", agent.name, agent.description, suffix)
|
|
99
|
+
end
|
|
100
|
+
parts[#parts + 1] = ""
|
|
101
|
+
if headless then
|
|
102
|
+
parts[#parts + 1] = table.concat({
|
|
103
|
+
"Actions:",
|
|
104
|
+
'- dispatch: start one or more tasks (one tasks[] entry each, run in parallel). Blocks until all dispatched tasks finish and returns their results.',
|
|
105
|
+
'- wait: block until previously dispatched jobs finish. Waits on the given ids[] (or all running jobs when omitted) and returns their results.',
|
|
106
|
+
'- status: non-blocking snapshot of job progress. Use sparingly; never call status in a loop.',
|
|
107
|
+
"",
|
|
108
|
+
"Rules:",
|
|
109
|
+
"- Batch independent tasks into a single dispatch call to maximize parallelism.",
|
|
110
|
+
"- Each agent runs one job at a time; dispatching to a busy agent is rejected.",
|
|
111
|
+
}, "\n")
|
|
112
|
+
else
|
|
113
|
+
parts[#parts + 1] = table.concat({
|
|
114
|
+
"Actions:",
|
|
115
|
+
'- dispatch: start one or more tasks (one tasks[] entry each, run in parallel).',
|
|
116
|
+
' - If you need the results to continue, or you have nothing else productive to do, set wait=true: the call blocks and returns the results directly. This is the right choice for fan-out/fan-in work (e.g. dispatching research and then synthesizing it).',
|
|
117
|
+
' - Omit wait ONLY when you have separate, independent work to do that does NOT overlap the dispatched tasks: dispatch returns immediately and you continue on that other work. Finished results are delivered automatically in a later message — do NOT poll for them, and NEVER fabricate or assume results you have not received.',
|
|
118
|
+
'- wait: block until previously dispatched jobs finish. Waits on the given ids[] (or all running jobs when omitted) and returns their results. Use when you reach a point where you need pending results before continuing.',
|
|
119
|
+
'- status: non-blocking snapshot of job progress. Use sparingly; never call status in a loop.',
|
|
120
|
+
"",
|
|
121
|
+
"Rules:",
|
|
122
|
+
"- Batch independent tasks into a single dispatch call to maximize parallelism.",
|
|
123
|
+
"- Each agent runs one job at a time; dispatching to a busy agent is rejected.",
|
|
124
|
+
"- NEVER duplicate the work you delegated. Once a task is dispatched, do not read the same files, run the same searches, or research the same questions yourself — that wastes context and defeats the purpose of delegating. Let the sub-agent do it.",
|
|
125
|
+
"- After a non-waiting dispatch, do only your separate independent work, then end your turn; the results arrive as an automated message. If you have no such independent work, you should have used wait=true instead.",
|
|
126
|
+
}, "\n")
|
|
127
|
+
end
|
|
128
|
+
return table.concat(parts, "\n")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
-- ---------------------------------------------------------------------------
|
|
132
|
+
-- Result formatting helpers
|
|
133
|
+
-- ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
-- Per-job character budget for results returned by wait (mirrors the Rust
|
|
136
|
+
-- auto-injection limit).
|
|
137
|
+
local MAX_RESULT_CHARS = 16000
|
|
138
|
+
|
|
139
|
+
--- Truncate to a byte budget without splitting a UTF-8 sequence.
|
|
140
|
+
local function truncate_result(s, max)
|
|
141
|
+
if #s <= max then
|
|
142
|
+
return s
|
|
143
|
+
end
|
|
144
|
+
local cut = max
|
|
145
|
+
while cut > 0 do
|
|
146
|
+
local b = s:byte(cut + 1)
|
|
147
|
+
if not b or b < 0x80 or b >= 0xC0 then
|
|
148
|
+
break
|
|
149
|
+
end
|
|
150
|
+
cut = cut - 1
|
|
151
|
+
end
|
|
152
|
+
return s:sub(1, cut) .. "\n[... truncated]"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
--- Format a ctx.agent.wait outcome into a report for the model.
|
|
156
|
+
local function format_wait_outcome(outcome)
|
|
157
|
+
local parts = {}
|
|
158
|
+
for _, job in ipairs(outcome.jobs or {}) do
|
|
159
|
+
local sym = job.status == "done" and "done" or "ERROR"
|
|
160
|
+
local body = truncate_result(job.result or "", MAX_RESULT_CHARS)
|
|
161
|
+
if job.result_file then
|
|
162
|
+
body = body .. string.format("\n[full output saved to: %s]", job.result_file)
|
|
163
|
+
end
|
|
164
|
+
parts[#parts + 1] = string.format(
|
|
165
|
+
"## %s (%s) — %s\n%s",
|
|
166
|
+
job.agent ~= "" and job.agent or "agent",
|
|
167
|
+
job.id,
|
|
168
|
+
sym,
|
|
169
|
+
body
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
if outcome.cancelled then
|
|
173
|
+
parts[#parts + 1] = "Wait cancelled by user; jobs keep running and their results will arrive automatically."
|
|
174
|
+
elseif outcome.timed_out then
|
|
175
|
+
parts[#parts + 1] = string.format(
|
|
176
|
+
"Wait timed out with jobs still running: %s. Their results will arrive automatically in a later message — do not assume their outcome.",
|
|
177
|
+
table.concat(outcome.pending or {}, ", ")
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
if #parts == 0 then
|
|
181
|
+
parts[#parts + 1] = "No jobs to wait on."
|
|
182
|
+
end
|
|
183
|
+
return table.concat(parts, "\n\n")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
-- ---------------------------------------------------------------------------
|
|
187
|
+
-- Tool execute function
|
|
188
|
+
-- ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
local function execute(params, ctx)
|
|
191
|
+
local action = params.action or ""
|
|
192
|
+
|
|
193
|
+
if action == "dispatch" then
|
|
194
|
+
local tasks = params.tasks or {}
|
|
195
|
+
if #tasks == 0 then
|
|
196
|
+
return "ERROR: Provide tasks for 'dispatch'."
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
local results = {}
|
|
200
|
+
local dispatched_ids = {}
|
|
201
|
+
local ok_count = 0
|
|
202
|
+
local err_count = 0
|
|
203
|
+
|
|
204
|
+
for _, t in ipairs(tasks) do
|
|
205
|
+
local agent_name = t.agent or ""
|
|
206
|
+
local task_desc = t.task or ""
|
|
207
|
+
|
|
208
|
+
-- Look up the agent definition
|
|
209
|
+
local agent_def = nil
|
|
210
|
+
for _, a in ipairs(subagents) do
|
|
211
|
+
if a.name == agent_name then
|
|
212
|
+
agent_def = a
|
|
213
|
+
break
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if agent_def then
|
|
218
|
+
-- Build spawn opts from the agent definition.
|
|
219
|
+
-- Busy agents are rejected atomically by the Rust registry.
|
|
220
|
+
local opts = {
|
|
221
|
+
agent = agent_name,
|
|
222
|
+
system_prompt = agent_def.system_prompt,
|
|
223
|
+
provider = agent_def.provider,
|
|
224
|
+
model = agent_def.model,
|
|
225
|
+
approval = agent_def.approval,
|
|
226
|
+
timeout_ms = agent_def.timeout_ms,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
local result = ctx.agent.spawn(task_desc, opts)
|
|
230
|
+
if result.ok then
|
|
231
|
+
results[#results + 1] = string.format(
|
|
232
|
+
"dispatched %s → %s", result.id, agent_name
|
|
233
|
+
)
|
|
234
|
+
dispatched_ids[#dispatched_ids + 1] = result.id
|
|
235
|
+
ok_count = ok_count + 1
|
|
236
|
+
else
|
|
237
|
+
results[#results + 1] = string.format(
|
|
238
|
+
"REJECTED: %s — %s", agent_name, result.error or "unknown"
|
|
239
|
+
)
|
|
240
|
+
err_count = err_count + 1
|
|
241
|
+
end
|
|
242
|
+
else
|
|
243
|
+
results[#results + 1] = string.format(
|
|
244
|
+
"REJECTED: unknown agent '%s'", agent_name
|
|
245
|
+
)
|
|
246
|
+
err_count = err_count + 1
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
local summary = string.format(
|
|
251
|
+
"Dispatched %d, rejected %d", ok_count, err_count
|
|
252
|
+
)
|
|
253
|
+
if err_count > 0 then
|
|
254
|
+
summary = summary .. "\n" .. table.concat(results, "\n")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
-- Blocking dispatch: wait for the dispatched jobs and return results.
|
|
258
|
+
-- Headless mode always blocks (no background auto-injection there).
|
|
259
|
+
local should_wait = params.wait or headless
|
|
260
|
+
if should_wait and #dispatched_ids > 0 then
|
|
261
|
+
local outcome = ctx.agent.wait(dispatched_ids, { timeout_ms = params.timeout_ms })
|
|
262
|
+
if outcome.ok then
|
|
263
|
+
summary = summary .. "\n\n" .. format_wait_outcome(outcome)
|
|
264
|
+
else
|
|
265
|
+
summary = summary .. "\n\nERROR waiting: " .. (outcome.error or "unknown")
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
return summary
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
if action == "wait" then
|
|
273
|
+
local outcome = ctx.agent.wait(params.ids, { timeout_ms = params.timeout_ms })
|
|
274
|
+
if not outcome.ok then
|
|
275
|
+
return "ERROR: " .. (outcome.error or "unknown")
|
|
276
|
+
end
|
|
277
|
+
return format_wait_outcome(outcome)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
if action == "status" then
|
|
281
|
+
local jobs = ctx.agent.jobs()
|
|
282
|
+
local parts = { "Sub-agent status:" }
|
|
283
|
+
for _, agent in ipairs(subagents) do
|
|
284
|
+
parts[#parts + 1] = string.format(" %s: %s", agent.name, agent_status(agent, jobs))
|
|
285
|
+
end
|
|
286
|
+
return table.concat(parts, "\n")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
return "ERROR: Action must be 'dispatch', 'wait' or 'status'."
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
-- ---------------------------------------------------------------------------
|
|
293
|
+
-- Register the tool
|
|
294
|
+
-- ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
bone.register_tool({
|
|
297
|
+
name = "subagent",
|
|
298
|
+
description = build_description(),
|
|
299
|
+
safety = "read_only",
|
|
300
|
+
parameters = {
|
|
301
|
+
type = "object",
|
|
302
|
+
properties = {
|
|
303
|
+
action = {
|
|
304
|
+
type = "string",
|
|
305
|
+
enum = { "dispatch", "wait", "status" },
|
|
306
|
+
description = "dispatch (start tasks), wait (block for results), or status (non-blocking snapshot)",
|
|
307
|
+
},
|
|
308
|
+
tasks = {
|
|
309
|
+
type = "array",
|
|
310
|
+
description = "dispatch only: list of tasks to start in parallel. Each item: {agent: string, task: string}",
|
|
311
|
+
items = {
|
|
312
|
+
type = "object",
|
|
313
|
+
properties = {
|
|
314
|
+
agent = {
|
|
315
|
+
type = "string",
|
|
316
|
+
description = "Registered agent name",
|
|
317
|
+
},
|
|
318
|
+
task = {
|
|
319
|
+
type = "string",
|
|
320
|
+
description = "Self-contained task description for the agent (it sees nothing else)",
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
required = { "agent", "task" },
|
|
324
|
+
additionalProperties = false,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
wait = {
|
|
328
|
+
type = "boolean",
|
|
329
|
+
description = "dispatch only: block until the dispatched tasks finish and return their results. Use when your next step depends on them.",
|
|
330
|
+
},
|
|
331
|
+
ids = {
|
|
332
|
+
type = "array",
|
|
333
|
+
items = { type = "string" },
|
|
334
|
+
description = "wait only: job ids to wait for (omit to wait for all running jobs)",
|
|
335
|
+
},
|
|
336
|
+
timeout_ms = {
|
|
337
|
+
type = "integer",
|
|
338
|
+
description = "dispatch(wait=true)/wait: max time to block in milliseconds (default 300000)",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
required = { "action" },
|
|
342
|
+
additionalProperties = false,
|
|
343
|
+
},
|
|
344
|
+
display = {
|
|
345
|
+
show = true,
|
|
346
|
+
show_result = false,
|
|
347
|
+
args = { "action", "tasks", "wait", "ids" },
|
|
348
|
+
},
|
|
349
|
+
execute = execute,
|
|
350
|
+
})
|