@bubblebrain-ai/bubble 0.0.3 → 0.0.5
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/README.md +8 -3
- package/dist/agent/budget-ledger.d.ts +20 -0
- package/dist/agent/budget-ledger.js +51 -0
- package/dist/agent/execution-governor.d.ts +14 -0
- package/dist/agent/execution-governor.js +172 -14
- package/dist/agent/profiles.d.ts +59 -0
- package/dist/agent/profiles.js +460 -0
- package/dist/agent/subagent-control.d.ts +52 -0
- package/dist/agent/subagent-control.js +38 -0
- package/dist/agent/task-classifier.d.ts +1 -1
- package/dist/agent/task-classifier.js +60 -0
- package/dist/agent/tool-intent.d.ts +14 -0
- package/dist/agent/tool-intent.js +125 -1
- package/dist/agent.d.ts +60 -1
- package/dist/agent.js +606 -53
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +45 -0
- package/dist/context/budget.js +1 -0
- package/dist/context/compact-llm.js +7 -6
- package/dist/context/compact.js +6 -6
- package/dist/context/projector.d.ts +3 -3
- package/dist/context/projector.js +32 -18
- package/dist/context/prune.d.ts +2 -2
- package/dist/context/prune.js +1 -4
- package/dist/main.d.ts +1 -1
- package/dist/main.js +13 -6
- package/dist/mcp/manager.js +1 -0
- package/dist/orchestrator/default-hooks.js +92 -1
- package/dist/orchestrator/hooks.d.ts +10 -0
- package/dist/prompt/compose.d.ts +1 -0
- package/dist/prompt/compose.js +20 -1
- package/dist/prompt/environment.js +21 -2
- package/dist/prompt/provider-prompts/deepseek.d.ts +1 -0
- package/dist/prompt/provider-prompts/deepseek.js +8 -0
- package/dist/prompt/provider-prompts/glm.d.ts +1 -0
- package/dist/prompt/provider-prompts/glm.js +7 -0
- package/dist/prompt/provider-prompts/kimi.d.ts +1 -0
- package/dist/prompt/provider-prompts/kimi.js +7 -0
- package/dist/prompt/reminders.d.ts +5 -1
- package/dist/prompt/reminders.js +51 -6
- package/dist/prompt/runtime.d.ts +1 -1
- package/dist/prompt/runtime.js +16 -3
- package/dist/prompt/task-reminders.d.ts +2 -0
- package/dist/prompt/task-reminders.js +56 -0
- package/dist/provider-artifacts.d.ts +7 -0
- package/dist/provider-artifacts.js +60 -0
- package/dist/provider.d.ts +6 -7
- package/dist/provider.js +77 -15
- package/dist/session-log.js +3 -1
- package/dist/slash-commands/commands.js +2 -3
- package/dist/system-prompt.d.ts +2 -0
- package/dist/tools/agent-lifecycle.d.ts +6 -0
- package/dist/tools/agent-lifecycle.js +355 -0
- package/dist/tools/bash.js +12 -7
- package/dist/tools/edit-apply.d.ts +25 -0
- package/dist/tools/edit-apply.js +197 -0
- package/dist/tools/edit.js +64 -52
- package/dist/tools/exit-plan-mode.js +3 -1
- package/dist/tools/file-mutation-queue.d.ts +1 -0
- package/dist/tools/file-mutation-queue.js +32 -0
- package/dist/tools/glob.js +1 -0
- package/dist/tools/grep.js +1 -0
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +3 -3
- package/dist/tools/lsp.js +2 -0
- package/dist/tools/memory.js +2 -0
- package/dist/tools/question.js +2 -0
- package/dist/tools/read.js +1 -0
- package/dist/tools/skill.js +1 -0
- package/dist/tools/task.js +1 -0
- package/dist/tools/todo.js +1 -0
- package/dist/tools/tool-search.js +2 -1
- package/dist/tools/web-fetch.js +1 -0
- package/dist/tools/web-search.js +1 -0
- package/dist/tools/write.js +10 -1
- package/dist/tui/display-history.d.ts +8 -1
- package/dist/tui/image-paste.d.ts +41 -0
- package/dist/tui/image-paste.js +217 -0
- package/dist/tui/markdown-inline.d.ts +22 -0
- package/dist/tui/markdown-inline.js +68 -0
- package/dist/tui/render-signature.d.ts +1 -0
- package/dist/tui/render-signature.js +7 -0
- package/dist/tui/run.js +814 -269
- package/dist/tui/tool-renderers/fallback.d.ts +2 -0
- package/dist/tui/tool-renderers/fallback.js +75 -0
- package/dist/tui/tool-renderers/registry.d.ts +3 -0
- package/dist/tui/tool-renderers/registry.js +11 -0
- package/dist/tui/tool-renderers/subagent.d.ts +2 -0
- package/dist/tui/tool-renderers/subagent.js +114 -0
- package/dist/tui/tool-renderers/types.d.ts +36 -0
- package/dist/tui/tool-renderers/types.js +1 -0
- package/dist/tui/tool-renderers/write-preview.d.ts +12 -0
- package/dist/tui/tool-renderers/write-preview.js +22 -0
- package/dist/tui/tool-renderers/write.d.ts +6 -0
- package/dist/tui/tool-renderers/write.js +82 -0
- package/dist/types.d.ts +90 -10
- package/package.json +3 -3
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { discoverAgentProfiles, findAgentProfile } from "../agent/profiles.js";
|
|
2
|
+
export function createSpawnAgentTool() {
|
|
3
|
+
return {
|
|
4
|
+
name: "spawn_agent",
|
|
5
|
+
readOnly: true,
|
|
6
|
+
effect: "read",
|
|
7
|
+
description: [
|
|
8
|
+
"Start a child subagent in the background and return its agent_id plus random nickname.",
|
|
9
|
+
"Use this for Codex-style delegation. The child has an independent thread; call wait_agent later to collect its result.",
|
|
10
|
+
"When the user asks to use a subagent, spawn first with a clear task instead of doing the delegated investigation yourself.",
|
|
11
|
+
"After spawning, do not duplicate the same delegated work locally; either wait for the child or do clearly non-overlapping work.",
|
|
12
|
+
"agent_type defaults to default. Built-in types include default, explorer, and worker.",
|
|
13
|
+
].join(" "),
|
|
14
|
+
parameters: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
agent_type: { type: "string", description: "Subagent profile or role name. Defaults to default." },
|
|
18
|
+
agent: { type: "string", description: "Alias for agent_type." },
|
|
19
|
+
message: { type: "string", description: "Initial task for the subagent." },
|
|
20
|
+
task: { type: "string", description: "Alias for message." },
|
|
21
|
+
fork_context: { type: "boolean", description: "When true, copy recent parent conversation into the child thread." },
|
|
22
|
+
agentScope: {
|
|
23
|
+
type: "string",
|
|
24
|
+
enum: ["user", "project", "both"],
|
|
25
|
+
description: "Which profile locations to load. Defaults to user profiles plus built-ins.",
|
|
26
|
+
},
|
|
27
|
+
allowProjectAgents: {
|
|
28
|
+
type: "boolean",
|
|
29
|
+
description: "Required to run profiles loaded from project-local .bubble/agents.",
|
|
30
|
+
},
|
|
31
|
+
approval: {
|
|
32
|
+
type: "string",
|
|
33
|
+
enum: ["fail", "disabled"],
|
|
34
|
+
description: "How this child handles tools that need interactive approval.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
39
|
+
async execute(args, ctx) {
|
|
40
|
+
if (!ctx.agent?.spawnSubAgent) {
|
|
41
|
+
return toolRuntimeMissing("spawn_agent");
|
|
42
|
+
}
|
|
43
|
+
const message = stringArg(args.message) ?? stringArg(args.task);
|
|
44
|
+
if (!message) {
|
|
45
|
+
return { content: "Error: spawn_agent requires message or task.", isError: true };
|
|
46
|
+
}
|
|
47
|
+
const profileName = stringArg(args.agent_type) ?? stringArg(args.agent) ?? "default";
|
|
48
|
+
const resolved = resolveProfile(ctx.cwd, profileName, parseScope(args.agentScope), args.allowProjectAgents === true);
|
|
49
|
+
if ("error" in resolved)
|
|
50
|
+
return resolved.error;
|
|
51
|
+
if (resolved.profile.mode !== "readonly") {
|
|
52
|
+
return unsupportedProfile(resolved.profile);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const snapshot = await ctx.agent.spawnSubAgent(message, ctx.cwd, {
|
|
56
|
+
profile: resolved.profile,
|
|
57
|
+
parentToolCallId: ctx.toolCall?.id ?? snapshotFallbackId(),
|
|
58
|
+
approval: parseApproval(args.approval),
|
|
59
|
+
abortSignal: ctx.abortSignal,
|
|
60
|
+
forkContext: args.fork_context === true,
|
|
61
|
+
});
|
|
62
|
+
return formatLifecycleResult("spawn_agent", [snapshot], [
|
|
63
|
+
`Spawned ${snapshot.nickname} (${snapshot.agentName})`,
|
|
64
|
+
`agent_id: ${snapshot.agentId}`,
|
|
65
|
+
`status: ${snapshot.status}`,
|
|
66
|
+
`next: call wait_agent for ${snapshot.agentId} to collect the delegated result`,
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return toolError("spawn_agent", error);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function createWaitAgentTool() {
|
|
76
|
+
return {
|
|
77
|
+
name: "wait_agent",
|
|
78
|
+
readOnly: true,
|
|
79
|
+
effect: "read",
|
|
80
|
+
description: [
|
|
81
|
+
"Wait for one or more spawned subagents to reach a final status and return snapshots.",
|
|
82
|
+
"If the wait times out while children are still running, call wait_agent again with a longer timeout instead of redoing the same delegated work locally.",
|
|
83
|
+
].join(" "),
|
|
84
|
+
parameters: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
agent_id: { type: "string", description: "A single agent id to wait for." },
|
|
88
|
+
agent_ids: {
|
|
89
|
+
type: "array",
|
|
90
|
+
description: "Agent ids to wait for. If omitted, waits for any active subagent.",
|
|
91
|
+
items: { type: "string" },
|
|
92
|
+
},
|
|
93
|
+
timeout_ms: { type: "number", description: "Maximum wait time in milliseconds. Defaults to 30000." },
|
|
94
|
+
},
|
|
95
|
+
additionalProperties: false,
|
|
96
|
+
},
|
|
97
|
+
async execute(args, ctx) {
|
|
98
|
+
if (!ctx.agent?.waitSubAgents) {
|
|
99
|
+
return toolRuntimeMissing("wait_agent");
|
|
100
|
+
}
|
|
101
|
+
const agentIds = normalizeAgentIds(args.agent_ids, args.agent_id);
|
|
102
|
+
try {
|
|
103
|
+
const snapshots = await ctx.agent.waitSubAgents({
|
|
104
|
+
agentIds,
|
|
105
|
+
timeoutMs: typeof args.timeout_ms === "number" ? args.timeout_ms : undefined,
|
|
106
|
+
});
|
|
107
|
+
if (snapshots.length === 0) {
|
|
108
|
+
return {
|
|
109
|
+
content: "No subagents reached a final status before the timeout.",
|
|
110
|
+
status: "timeout",
|
|
111
|
+
metadata: { kind: "subagent", mode: "lifecycle", subagents: [] },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (snapshots.some((snapshot) => !isFinalSnapshotStatus(snapshot.status))) {
|
|
115
|
+
return formatLifecycleResult("wait_agent", snapshots, [
|
|
116
|
+
"wait_agent timed out before a delegated result was ready.",
|
|
117
|
+
"The subagent is still running; call wait_agent again with a longer timeout instead of duplicating the same work locally.",
|
|
118
|
+
"",
|
|
119
|
+
...snapshots.flatMap((snapshot) => [...formatSnapshot(snapshot), ""]),
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
return formatLifecycleResult("wait_agent", snapshots);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return toolError("wait_agent", error);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function createSendInputTool() {
|
|
131
|
+
return {
|
|
132
|
+
name: "send_input",
|
|
133
|
+
readOnly: true,
|
|
134
|
+
effect: "read",
|
|
135
|
+
description: "Send a follow-up message to an existing subagent thread. If it is still running, pass interrupt:true to cancel and redirect it.",
|
|
136
|
+
parameters: {
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: {
|
|
139
|
+
agent_id: { type: "string", description: "Target subagent id." },
|
|
140
|
+
message: { type: "string", description: "Follow-up message." },
|
|
141
|
+
task: { type: "string", description: "Alias for message." },
|
|
142
|
+
interrupt: { type: "boolean", description: "Cancel a running child before applying this input." },
|
|
143
|
+
},
|
|
144
|
+
required: ["agent_id"],
|
|
145
|
+
additionalProperties: false,
|
|
146
|
+
},
|
|
147
|
+
async execute(args, ctx) {
|
|
148
|
+
if (!ctx.agent?.sendSubAgentInput) {
|
|
149
|
+
return toolRuntimeMissing("send_input");
|
|
150
|
+
}
|
|
151
|
+
const agentId = stringArg(args.agent_id);
|
|
152
|
+
const message = stringArg(args.message) ?? stringArg(args.task);
|
|
153
|
+
if (!agentId || !message) {
|
|
154
|
+
return { content: "Error: send_input requires agent_id and message.", isError: true };
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const snapshot = await ctx.agent.sendSubAgentInput(agentId, message, ctx.cwd, {
|
|
158
|
+
interrupt: args.interrupt === true,
|
|
159
|
+
parentToolCallId: ctx.toolCall?.id,
|
|
160
|
+
abortSignal: ctx.abortSignal,
|
|
161
|
+
});
|
|
162
|
+
return formatLifecycleResult("send_input", [snapshot], [
|
|
163
|
+
`Sent input to ${snapshot.nickname} (${snapshot.agentName})`,
|
|
164
|
+
`agent_id: ${snapshot.agentId}`,
|
|
165
|
+
`status: ${snapshot.status}`,
|
|
166
|
+
]);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
return toolError("send_input", error);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function createCloseAgentTool() {
|
|
175
|
+
return {
|
|
176
|
+
name: "close_agent",
|
|
177
|
+
readOnly: true,
|
|
178
|
+
effect: "read",
|
|
179
|
+
description: "Close a spawned subagent only when the delegated task is cancelled, stale, or no longer needed. Running children are cancelled before closing; do not close a child just because you started doing the same delegated work locally.",
|
|
180
|
+
parameters: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: {
|
|
183
|
+
agent_id: { type: "string", description: "Subagent id to close." },
|
|
184
|
+
},
|
|
185
|
+
required: ["agent_id"],
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
},
|
|
188
|
+
async execute(args, ctx) {
|
|
189
|
+
if (!ctx.agent?.closeSubAgent) {
|
|
190
|
+
return toolRuntimeMissing("close_agent");
|
|
191
|
+
}
|
|
192
|
+
const agentId = stringArg(args.agent_id);
|
|
193
|
+
if (!agentId) {
|
|
194
|
+
return { content: "Error: close_agent requires agent_id.", isError: true };
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const snapshot = await ctx.agent.closeSubAgent(agentId);
|
|
198
|
+
return formatLifecycleResult("close_agent", [snapshot], [
|
|
199
|
+
`Closed ${snapshot.nickname} (${snapshot.agentName})`,
|
|
200
|
+
`agent_id: ${snapshot.agentId}`,
|
|
201
|
+
`status: ${snapshot.status}`,
|
|
202
|
+
]);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
return toolError("close_agent", error);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
export function createAgentLifecycleTools() {
|
|
211
|
+
return [
|
|
212
|
+
createSpawnAgentTool(),
|
|
213
|
+
createWaitAgentTool(),
|
|
214
|
+
createSendInputTool(),
|
|
215
|
+
createCloseAgentTool(),
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
function resolveProfile(cwd, name, scope, allowProjectAgents) {
|
|
219
|
+
const discovered = discoverAgentProfiles(cwd, scope);
|
|
220
|
+
const profile = findAgentProfile(discovered.profiles, name);
|
|
221
|
+
if (!profile) {
|
|
222
|
+
const available = discovered.profiles.map((item) => item.name).sort().join(", ") || "none";
|
|
223
|
+
return {
|
|
224
|
+
error: {
|
|
225
|
+
content: `Error: unknown subagent profile "${name}". Available profiles: ${available}`,
|
|
226
|
+
isError: true,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (profile.source === "project" && !allowProjectAgents) {
|
|
231
|
+
return {
|
|
232
|
+
error: {
|
|
233
|
+
content: [
|
|
234
|
+
`Blocked: subagent profile "${profile.name}" was loaded from project-local .bubble/agents.`,
|
|
235
|
+
"Pass allowProjectAgents: true only when you trust this repository's agent profile prompts.",
|
|
236
|
+
].join("\n"),
|
|
237
|
+
isError: true,
|
|
238
|
+
status: "blocked",
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { profile };
|
|
243
|
+
}
|
|
244
|
+
function formatLifecycleResult(toolName, snapshots, header) {
|
|
245
|
+
const lines = header ?? [`${toolName}: ${snapshots.length} subagent${snapshots.length === 1 ? "" : "s"}`];
|
|
246
|
+
if (!header)
|
|
247
|
+
lines.push("");
|
|
248
|
+
if (!header) {
|
|
249
|
+
for (const snapshot of snapshots) {
|
|
250
|
+
lines.push(...formatSnapshot(snapshot), "");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
content: lines.join("\n").trim(),
|
|
255
|
+
status: lifecycleStatus(toolName, snapshots),
|
|
256
|
+
isError: snapshots.length > 0 && snapshots.every((snapshot) => snapshot.status === "failed" || snapshot.status === "blocked"),
|
|
257
|
+
metadata: {
|
|
258
|
+
kind: "subagent",
|
|
259
|
+
mode: "lifecycle",
|
|
260
|
+
subagents: snapshots.map(snapshotToMetadata),
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function lifecycleStatus(toolName, snapshots) {
|
|
265
|
+
if (toolName === "spawn_agent" || toolName === "send_input" || toolName === "close_agent") {
|
|
266
|
+
return "success";
|
|
267
|
+
}
|
|
268
|
+
if (snapshots.some((snapshot) => !isFinalSnapshotStatus(snapshot.status))) {
|
|
269
|
+
return "timeout";
|
|
270
|
+
}
|
|
271
|
+
return snapshots.every((snapshot) => snapshot.status === "completed" || snapshot.status === "closed") ? "success" : "partial";
|
|
272
|
+
}
|
|
273
|
+
function isFinalSnapshotStatus(status) {
|
|
274
|
+
return status === "completed"
|
|
275
|
+
|| status === "failed"
|
|
276
|
+
|| status === "blocked"
|
|
277
|
+
|| status === "cancelled"
|
|
278
|
+
|| status === "closed";
|
|
279
|
+
}
|
|
280
|
+
function formatSnapshot(snapshot) {
|
|
281
|
+
const label = `${snapshot.nickname} (${snapshot.agentName})`;
|
|
282
|
+
const lines = [
|
|
283
|
+
`## ${label}`,
|
|
284
|
+
`agent_id: ${snapshot.agentId}`,
|
|
285
|
+
`status: ${snapshot.status}`,
|
|
286
|
+
`task: ${snapshot.task}`,
|
|
287
|
+
];
|
|
288
|
+
if (snapshot.summary) {
|
|
289
|
+
lines.push("", "Summary:", snapshot.summary);
|
|
290
|
+
}
|
|
291
|
+
else if (snapshot.status === "completed") {
|
|
292
|
+
lines.push("", "Summary: (no final text summary was produced)");
|
|
293
|
+
}
|
|
294
|
+
if (snapshot.toolNotes.length > 0) {
|
|
295
|
+
lines.push("", "Recent tool notes:", ...snapshot.toolNotes.slice(-8).map((note) => `- ${note}`));
|
|
296
|
+
}
|
|
297
|
+
if (snapshot.error) {
|
|
298
|
+
lines.push("", `Error: ${snapshot.error}`);
|
|
299
|
+
}
|
|
300
|
+
return lines;
|
|
301
|
+
}
|
|
302
|
+
function snapshotToMetadata(snapshot) {
|
|
303
|
+
return {
|
|
304
|
+
subAgentId: snapshot.agentId,
|
|
305
|
+
agentName: snapshot.agentName,
|
|
306
|
+
nickname: snapshot.nickname,
|
|
307
|
+
status: snapshot.status === "closed" ? "cancelled" : snapshot.status,
|
|
308
|
+
profileSource: snapshot.profileSource,
|
|
309
|
+
task: snapshot.task,
|
|
310
|
+
summary: snapshot.summary,
|
|
311
|
+
toolNotes: snapshot.toolNotes,
|
|
312
|
+
usage: snapshot.usage,
|
|
313
|
+
error: snapshot.error,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function unsupportedProfile(profile) {
|
|
317
|
+
return {
|
|
318
|
+
content: `Error: subagent profile "${profile.name}" uses mode "${profile.mode}", but this runtime only supports readonly lifecycle subagents.`,
|
|
319
|
+
isError: true,
|
|
320
|
+
status: "blocked",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function parseScope(value) {
|
|
324
|
+
return value === "project" || value === "both" ? value : "user";
|
|
325
|
+
}
|
|
326
|
+
function parseApproval(value) {
|
|
327
|
+
return value === "fail" || value === "disabled" ? value : undefined;
|
|
328
|
+
}
|
|
329
|
+
function normalizeAgentIds(value, single) {
|
|
330
|
+
const out = [];
|
|
331
|
+
if (typeof single === "string" && single.trim())
|
|
332
|
+
out.push(single.trim());
|
|
333
|
+
if (Array.isArray(value)) {
|
|
334
|
+
for (const item of value) {
|
|
335
|
+
if (typeof item === "string" && item.trim())
|
|
336
|
+
out.push(item.trim());
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out.length > 0 ? [...new Set(out)] : undefined;
|
|
340
|
+
}
|
|
341
|
+
function stringArg(value) {
|
|
342
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
343
|
+
}
|
|
344
|
+
function snapshotFallbackId() {
|
|
345
|
+
return `spawn_${Date.now().toString(36)}`;
|
|
346
|
+
}
|
|
347
|
+
function toolRuntimeMissing(name) {
|
|
348
|
+
return { content: `Error: ${name} requires an agent runtime`, isError: true };
|
|
349
|
+
}
|
|
350
|
+
function toolError(name, error) {
|
|
351
|
+
return {
|
|
352
|
+
content: `Error executing ${name}: ${error?.message || String(error)}`,
|
|
353
|
+
isError: true,
|
|
354
|
+
};
|
|
355
|
+
}
|
package/dist/tools/bash.js
CHANGED
|
@@ -5,12 +5,14 @@ import { spawn } from "node:child_process";
|
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { platform } from "node:os";
|
|
7
7
|
import { gateToolAction } from "../approval/tool-helper.js";
|
|
8
|
-
import { parseSearchBashCommand } from "../agent/tool-intent.js";
|
|
8
|
+
import { parseReadBashCommand, parseSearchBashCommand } from "../agent/tool-intent.js";
|
|
9
9
|
import { referencesSensitivePath } from "./sensitive-paths.js";
|
|
10
10
|
const MAX_OUTPUT = 50 * 1024;
|
|
11
11
|
export function createBashTool(cwd, approval) {
|
|
12
12
|
return {
|
|
13
13
|
name: "bash",
|
|
14
|
+
effect: "unknown",
|
|
15
|
+
requiresApproval: true,
|
|
14
16
|
description: "Execute a bash command in the working directory. Use timeout for long-running commands.",
|
|
15
17
|
parameters: {
|
|
16
18
|
type: "object",
|
|
@@ -27,6 +29,7 @@ export function createBashTool(cwd, approval) {
|
|
|
27
29
|
const command = String(args.command);
|
|
28
30
|
const timeoutSec = typeof args.timeout === "number" ? args.timeout : 60;
|
|
29
31
|
const parsedSearch = parseSearchBashCommand(command);
|
|
32
|
+
const parsedRead = parsedSearch ? undefined : parseReadBashCommand(command);
|
|
30
33
|
if (referencesSensitivePath(command)) {
|
|
31
34
|
return {
|
|
32
35
|
content: "Error: Bash access to sensitive credential storage is blocked.",
|
|
@@ -95,9 +98,9 @@ export function createBashTool(cwd, approval) {
|
|
|
95
98
|
isError: true,
|
|
96
99
|
status: "timeout",
|
|
97
100
|
metadata: {
|
|
98
|
-
kind: parsedSearch ? "search" : "shell",
|
|
101
|
+
kind: parsedSearch ? "search" : parsedRead ? "read" : "shell",
|
|
99
102
|
pattern: parsedSearch?.pattern,
|
|
100
|
-
path: parsedSearch?.path,
|
|
103
|
+
path: parsedSearch?.path ?? parsedRead?.path,
|
|
101
104
|
},
|
|
102
105
|
});
|
|
103
106
|
return;
|
|
@@ -109,9 +112,9 @@ export function createBashTool(cwd, approval) {
|
|
|
109
112
|
isError: true,
|
|
110
113
|
status: "blocked",
|
|
111
114
|
metadata: {
|
|
112
|
-
kind: parsedSearch ? "search" : "shell",
|
|
115
|
+
kind: parsedSearch ? "search" : parsedRead ? "read" : "shell",
|
|
113
116
|
pattern: parsedSearch?.pattern,
|
|
114
|
-
path: parsedSearch?.path,
|
|
117
|
+
path: parsedSearch?.path ?? parsedRead?.path,
|
|
115
118
|
reason: "cancelled",
|
|
116
119
|
},
|
|
117
120
|
});
|
|
@@ -131,6 +134,7 @@ export function createBashTool(cwd, approval) {
|
|
|
131
134
|
kind: "search",
|
|
132
135
|
pattern: parsedSearch.pattern,
|
|
133
136
|
path: parsedSearch.path,
|
|
137
|
+
command,
|
|
134
138
|
matches: 0,
|
|
135
139
|
},
|
|
136
140
|
});
|
|
@@ -142,9 +146,10 @@ export function createBashTool(cwd, approval) {
|
|
|
142
146
|
isError,
|
|
143
147
|
status: isError ? "command_error" : "success",
|
|
144
148
|
metadata: {
|
|
145
|
-
kind: parsedSearch ? "search" : "shell",
|
|
149
|
+
kind: parsedSearch ? "search" : parsedRead ? "read" : "shell",
|
|
146
150
|
pattern: parsedSearch?.pattern,
|
|
147
|
-
path: parsedSearch?.path,
|
|
151
|
+
path: parsedSearch?.path ?? parsedRead?.path,
|
|
152
|
+
command,
|
|
148
153
|
matches: parsedSearch ? countSearchMatches(stdout) : undefined,
|
|
149
154
|
},
|
|
150
155
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface EditOperation {
|
|
2
|
+
oldText: string;
|
|
3
|
+
newText: string;
|
|
4
|
+
}
|
|
5
|
+
export type EditMatchMode = "exact" | "normalized-line";
|
|
6
|
+
export interface EditMatchInfo {
|
|
7
|
+
editIndex: number;
|
|
8
|
+
mode: EditMatchMode;
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
}
|
|
12
|
+
export interface AppliedEditResult {
|
|
13
|
+
content: string;
|
|
14
|
+
normalizedOriginal: string;
|
|
15
|
+
normalizedNext: string;
|
|
16
|
+
bom: string;
|
|
17
|
+
lineEnding: "\n" | "\r\n";
|
|
18
|
+
matches: EditMatchInfo[];
|
|
19
|
+
}
|
|
20
|
+
export declare class EditApplyError extends Error {
|
|
21
|
+
readonly status: "no_match" | "blocked";
|
|
22
|
+
constructor(message: string, status?: "no_match" | "blocked");
|
|
23
|
+
}
|
|
24
|
+
export declare function applyEditsToContent(rawContent: string, edits: EditOperation[]): AppliedEditResult;
|
|
25
|
+
export declare function formatEditMatchNotes(matches: EditMatchInfo[]): string;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
export class EditApplyError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(message, status = "no_match") {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.name = "EditApplyError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function detectLineEnding(content) {
|
|
10
|
+
const crlf = content.indexOf("\r\n");
|
|
11
|
+
const lf = content.indexOf("\n");
|
|
12
|
+
return crlf !== -1 && crlf === lf - 1 ? "\r\n" : "\n";
|
|
13
|
+
}
|
|
14
|
+
function stripBom(content) {
|
|
15
|
+
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
|
|
16
|
+
}
|
|
17
|
+
function normalizeToLF(text) {
|
|
18
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
19
|
+
}
|
|
20
|
+
function restoreLineEndings(text, lineEnding) {
|
|
21
|
+
return lineEnding === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
22
|
+
}
|
|
23
|
+
function normalizeLineForMatch(line) {
|
|
24
|
+
return line
|
|
25
|
+
.normalize("NFKC")
|
|
26
|
+
.trimEnd()
|
|
27
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
28
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
29
|
+
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
30
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
31
|
+
}
|
|
32
|
+
function findAllOccurrences(content, needle) {
|
|
33
|
+
const indexes = [];
|
|
34
|
+
if (needle.length === 0)
|
|
35
|
+
return indexes;
|
|
36
|
+
let index = content.indexOf(needle);
|
|
37
|
+
while (index !== -1) {
|
|
38
|
+
indexes.push(index);
|
|
39
|
+
index = content.indexOf(needle, index + needle.length);
|
|
40
|
+
}
|
|
41
|
+
return indexes;
|
|
42
|
+
}
|
|
43
|
+
function splitLines(content) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
let start = 0;
|
|
46
|
+
for (let i = 0; i < content.length; i++) {
|
|
47
|
+
if (content[i] === "\n") {
|
|
48
|
+
lines.push({ text: content.slice(start, i), start, endNoNewline: i });
|
|
49
|
+
start = i + 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
lines.push({ text: content.slice(start), start, endNoNewline: content.length });
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
function nonBlankLines(lines) {
|
|
56
|
+
const result = [];
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
const normalized = normalizeLineForMatch(lines[i].text);
|
|
59
|
+
if (normalized.trim().length > 0) {
|
|
60
|
+
result.push({ lineIndex: i, normalized });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
function normalizedOldNonBlankLines(oldText) {
|
|
66
|
+
return splitLines(oldText)
|
|
67
|
+
.map((line) => normalizeLineForMatch(line.text))
|
|
68
|
+
.filter((line) => line.trim().length > 0);
|
|
69
|
+
}
|
|
70
|
+
function findNormalizedLineMatches(content, oldText) {
|
|
71
|
+
const contentLines = splitLines(content);
|
|
72
|
+
const searchable = nonBlankLines(contentLines);
|
|
73
|
+
const oldLines = normalizedOldNonBlankLines(oldText);
|
|
74
|
+
if (oldLines.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
const matches = [];
|
|
77
|
+
for (let i = 0; i <= searchable.length - oldLines.length; i++) {
|
|
78
|
+
let matched = true;
|
|
79
|
+
for (let j = 0; j < oldLines.length; j++) {
|
|
80
|
+
if (searchable[i + j].normalized !== oldLines[j]) {
|
|
81
|
+
matched = false;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (matched) {
|
|
86
|
+
const first = contentLines[searchable[i].lineIndex];
|
|
87
|
+
const last = contentLines[searchable[i + oldLines.length - 1].lineIndex];
|
|
88
|
+
matches.push({ start: first.start, end: last.endNoNewline });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return matches;
|
|
92
|
+
}
|
|
93
|
+
function summarizeOldText(oldText) {
|
|
94
|
+
const firstLine = normalizeToLF(oldText).split("\n").find((line) => line.trim().length > 0) ?? oldText;
|
|
95
|
+
return firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine;
|
|
96
|
+
}
|
|
97
|
+
function findBestLineHint(content, oldText) {
|
|
98
|
+
const oldLines = normalizedOldNonBlankLines(oldText);
|
|
99
|
+
if (oldLines.length === 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
const contentLines = nonBlankLines(splitLines(content));
|
|
102
|
+
let best;
|
|
103
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
104
|
+
let score = 0;
|
|
105
|
+
for (let j = 0; j < oldLines.length && i + j < contentLines.length; j++) {
|
|
106
|
+
if (contentLines[i + j].normalized === oldLines[j])
|
|
107
|
+
score++;
|
|
108
|
+
}
|
|
109
|
+
if (!best || score > best.score)
|
|
110
|
+
best = { index: i, score };
|
|
111
|
+
}
|
|
112
|
+
if (!best || best.score === 0)
|
|
113
|
+
return undefined;
|
|
114
|
+
const startLine = contentLines[best.index].lineIndex + 1;
|
|
115
|
+
return `Closest line-based candidate starts near line ${startLine} and matched ${best.score}/${oldLines.length} non-blank lines.`;
|
|
116
|
+
}
|
|
117
|
+
function matchEdit(content, edit, index, total) {
|
|
118
|
+
if (edit.oldText.length === 0) {
|
|
119
|
+
throw new EditApplyError(total === 1 ? "Error: oldText must not be empty." : `Error: edits[${index}].oldText must not be empty.`);
|
|
120
|
+
}
|
|
121
|
+
const oldText = normalizeToLF(edit.oldText);
|
|
122
|
+
const exact = findAllOccurrences(content, oldText);
|
|
123
|
+
if (exact.length === 1) {
|
|
124
|
+
return { editIndex: index, mode: "exact", start: exact[0], end: exact[0] + oldText.length };
|
|
125
|
+
}
|
|
126
|
+
if (exact.length > 1) {
|
|
127
|
+
throw new EditApplyError(total === 1
|
|
128
|
+
? `Error: oldText appears ${exact.length} times in file. Must be unique: "${summarizeOldText(oldText)}"`
|
|
129
|
+
: `Error: edits[${index}].oldText appears ${exact.length} times in file. Must be unique: "${summarizeOldText(oldText)}"`);
|
|
130
|
+
}
|
|
131
|
+
const normalizedLineMatches = findNormalizedLineMatches(content, oldText);
|
|
132
|
+
if (normalizedLineMatches.length === 1) {
|
|
133
|
+
return {
|
|
134
|
+
editIndex: index,
|
|
135
|
+
mode: "normalized-line",
|
|
136
|
+
start: normalizedLineMatches[0].start,
|
|
137
|
+
end: normalizedLineMatches[0].end,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (normalizedLineMatches.length > 1) {
|
|
141
|
+
throw new EditApplyError(total === 1
|
|
142
|
+
? `Error: oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`
|
|
143
|
+
: `Error: edits[${index}].oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`);
|
|
144
|
+
}
|
|
145
|
+
const hint = findBestLineHint(content, oldText);
|
|
146
|
+
const suffix = hint ? `\n${hint}` : "";
|
|
147
|
+
throw new EditApplyError(total === 1
|
|
148
|
+
? `Error: oldText not found in file: "${summarizeOldText(oldText)}"${suffix}`
|
|
149
|
+
: `Error: edits[${index}].oldText not found in file: "${summarizeOldText(oldText)}"${suffix}`);
|
|
150
|
+
}
|
|
151
|
+
function assertNoOverlaps(matches) {
|
|
152
|
+
const sorted = [...matches].sort((a, b) => a.start - b.start);
|
|
153
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
154
|
+
const previous = sorted[i - 1];
|
|
155
|
+
const current = sorted[i];
|
|
156
|
+
if (previous.end > current.start) {
|
|
157
|
+
throw new EditApplyError(`Error: edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in file. Merge them into one edit or target disjoint regions.`, "blocked");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function applyEditsToContent(rawContent, edits) {
|
|
162
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
163
|
+
throw new EditApplyError("Error: No edits provided");
|
|
164
|
+
}
|
|
165
|
+
const { bom, text } = stripBom(rawContent);
|
|
166
|
+
const lineEnding = detectLineEnding(text);
|
|
167
|
+
const normalizedOriginal = normalizeToLF(text);
|
|
168
|
+
const normalizedEdits = edits.map((edit) => ({
|
|
169
|
+
oldText: normalizeToLF(edit.oldText),
|
|
170
|
+
newText: normalizeToLF(edit.newText),
|
|
171
|
+
}));
|
|
172
|
+
const matches = normalizedEdits.map((edit, index) => matchEdit(normalizedOriginal, edit, index, normalizedEdits.length));
|
|
173
|
+
assertNoOverlaps(matches);
|
|
174
|
+
const byDescendingStart = [...matches].sort((a, b) => b.start - a.start);
|
|
175
|
+
let normalizedNext = normalizedOriginal;
|
|
176
|
+
for (const match of byDescendingStart) {
|
|
177
|
+
const edit = normalizedEdits[match.editIndex];
|
|
178
|
+
normalizedNext = normalizedNext.slice(0, match.start) + edit.newText + normalizedNext.slice(match.end);
|
|
179
|
+
}
|
|
180
|
+
if (normalizedNext === normalizedOriginal) {
|
|
181
|
+
throw new EditApplyError("Error: No changes made. The replacement produced identical content.");
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
content: bom + restoreLineEndings(normalizedNext, lineEnding),
|
|
185
|
+
normalizedOriginal,
|
|
186
|
+
normalizedNext,
|
|
187
|
+
bom,
|
|
188
|
+
lineEnding,
|
|
189
|
+
matches,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export function formatEditMatchNotes(matches) {
|
|
193
|
+
const normalizedCount = matches.filter((match) => match.mode !== "exact").length;
|
|
194
|
+
if (normalizedCount === 0)
|
|
195
|
+
return "";
|
|
196
|
+
return `\n\nNote: ${normalizedCount} edit${normalizedCount === 1 ? "" : "s"} applied using normalized line matching for whitespace/formatting differences.`;
|
|
197
|
+
}
|