@bubblebrain-ai/bubble 0.0.4 → 0.0.6
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/dist/agent/budget-ledger.d.ts +20 -0
- package/dist/agent/budget-ledger.js +51 -0
- package/dist/agent/execution-governor.js +1 -1
- 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-size.d.ts +9 -0
- package/dist/agent/task-size.js +33 -0
- package/dist/agent/tool-intent.d.ts +1 -0
- package/dist/agent/tool-intent.js +1 -1
- package/dist/agent.d.ts +60 -1
- package/dist/agent.js +648 -55
- 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.js +12 -5
- package/dist/mcp/manager.js +1 -0
- package/dist/orchestrator/default-hooks.js +85 -35
- package/dist/orchestrator/hooks.d.ts +5 -3
- package/dist/prompt/compose.d.ts +1 -0
- package/dist/prompt/compose.js +11 -1
- package/dist/prompt/environment.js +23 -2
- package/dist/prompt/provider-prompts/deepseek.js +1 -2
- package/dist/prompt/provider-prompts/kimi.js +1 -2
- package/dist/prompt/reminders.d.ts +21 -2
- package/dist/prompt/reminders.js +53 -8
- package/dist/prompt/runtime.d.ts +1 -1
- package/dist/prompt/runtime.js +17 -23
- package/dist/provider-artifacts.d.ts +7 -0
- package/dist/provider-artifacts.js +60 -0
- package/dist/provider.d.ts +16 -8
- package/dist/provider.js +149 -34
- package/dist/session-log.js +3 -1
- 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.d.ts +2 -1
- package/dist/tools/bash.js +3 -1
- package/dist/tools/edit-apply.d.ts +25 -0
- package/dist/tools/edit-apply.js +228 -0
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +75 -56
- 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/file-state.d.ts +25 -0
- package/dist/tools/file-state.js +52 -0
- package/dist/tools/glob.js +1 -0
- package/dist/tools/grep.js +1 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.js +9 -7
- 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.d.ts +2 -1
- package/dist/tools/read.js +6 -1
- 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.d.ts +4 -3
- package/dist/tools/write.js +135 -54
- package/dist/tui/display-history.d.ts +10 -1
- 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 +811 -274
- package/dist/tui/streaming-tool-args.d.ts +15 -0
- package/dist/tui/streaming-tool-args.js +30 -0
- 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 +30 -0
- package/dist/tui/tool-renderers/write.d.ts +6 -0
- package/dist/tui/tool-renderers/write.js +88 -0
- package/dist/types.d.ts +105 -10
- package/package.json +1 -1
|
@@ -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.d.ts
CHANGED
|
@@ -3,4 +3,5 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ApprovalController } from "../approval/types.js";
|
|
5
5
|
import type { ToolRegistryEntry } from "../types.js";
|
|
6
|
-
|
|
6
|
+
import type { FileStateTracker } from "./file-state.js";
|
|
7
|
+
export declare function createBashTool(cwd: string, approval?: ApprovalController, _fileState?: FileStateTracker): ToolRegistryEntry;
|
package/dist/tools/bash.js
CHANGED
|
@@ -8,9 +8,11 @@ import { gateToolAction } from "../approval/tool-helper.js";
|
|
|
8
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
|
-
export function createBashTool(cwd, approval) {
|
|
11
|
+
export function createBashTool(cwd, approval, _fileState) {
|
|
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",
|
|
@@ -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,228 @@
|
|
|
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
|
+
if (edit.oldText === edit.newText) {
|
|
122
|
+
const header = total === 1
|
|
123
|
+
? "Error: This edit is a no-op because oldText and newText are byte-identical."
|
|
124
|
+
: `Error: edits[${index}] is a no-op because oldText and newText are byte-identical.`;
|
|
125
|
+
throw new EditApplyError([
|
|
126
|
+
header,
|
|
127
|
+
"",
|
|
128
|
+
"Common causes and how to escape:",
|
|
129
|
+
"- Your tokenizer may be folding repeated characters into a single token (hex colors like '#ec489' vs '#ec4899', repeated digits, etc.). The two strings feel different in your head but serialize to identical bytes.",
|
|
130
|
+
"- Use the write tool with overwrite=true and the full new content for full-file replacements that hinge on a single repeated character or trailing digit.",
|
|
131
|
+
"- Or re-read the file with the read tool, then copy the exact bytes you want to replace before retrying.",
|
|
132
|
+
].join("\n"));
|
|
133
|
+
}
|
|
134
|
+
const oldText = normalizeToLF(edit.oldText);
|
|
135
|
+
const exact = findAllOccurrences(content, oldText);
|
|
136
|
+
if (exact.length === 1) {
|
|
137
|
+
return { editIndex: index, mode: "exact", start: exact[0], end: exact[0] + oldText.length };
|
|
138
|
+
}
|
|
139
|
+
if (exact.length > 1) {
|
|
140
|
+
const recovery = [
|
|
141
|
+
"",
|
|
142
|
+
"Extend oldText with more surrounding context (the lines immediately before/after) until it uniquely identifies the intended span.",
|
|
143
|
+
].join("\n");
|
|
144
|
+
throw new EditApplyError(total === 1
|
|
145
|
+
? `Error: oldText appears ${exact.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`
|
|
146
|
+
: `Error: edits[${index}].oldText appears ${exact.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`);
|
|
147
|
+
}
|
|
148
|
+
const normalizedLineMatches = findNormalizedLineMatches(content, oldText);
|
|
149
|
+
if (normalizedLineMatches.length === 1) {
|
|
150
|
+
return {
|
|
151
|
+
editIndex: index,
|
|
152
|
+
mode: "normalized-line",
|
|
153
|
+
start: normalizedLineMatches[0].start,
|
|
154
|
+
end: normalizedLineMatches[0].end,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (normalizedLineMatches.length > 1) {
|
|
158
|
+
throw new EditApplyError(total === 1
|
|
159
|
+
? `Error: oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`
|
|
160
|
+
: `Error: edits[${index}].oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`);
|
|
161
|
+
}
|
|
162
|
+
const hint = findBestLineHint(content, oldText);
|
|
163
|
+
const hintSuffix = hint ? `\n${hint}` : "";
|
|
164
|
+
const recovery = [
|
|
165
|
+
"",
|
|
166
|
+
"How to recover:",
|
|
167
|
+
"- Re-read the file with the read tool to see its current bytes; the file may have been changed by a prior edit this turn.",
|
|
168
|
+
"- Shorten oldText to a smaller unique anchor and try again. Long multi-line anchors are fragile to whitespace and indentation.",
|
|
169
|
+
"- If many lines need to change, use the write tool with overwrite=true and the full new content instead of stacking edits.",
|
|
170
|
+
].join("\n");
|
|
171
|
+
throw new EditApplyError(total === 1
|
|
172
|
+
? `Error: oldText not found in file: "${summarizeOldText(oldText)}"${hintSuffix}${recovery}`
|
|
173
|
+
: `Error: edits[${index}].oldText not found in file: "${summarizeOldText(oldText)}"${hintSuffix}${recovery}`);
|
|
174
|
+
}
|
|
175
|
+
function assertNoOverlaps(matches) {
|
|
176
|
+
const sorted = [...matches].sort((a, b) => a.start - b.start);
|
|
177
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
178
|
+
const previous = sorted[i - 1];
|
|
179
|
+
const current = sorted[i];
|
|
180
|
+
if (previous.end > current.start) {
|
|
181
|
+
throw new EditApplyError(`Error: edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in file. Merge them into one edit or target disjoint regions.`, "blocked");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export function applyEditsToContent(rawContent, edits) {
|
|
186
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
187
|
+
throw new EditApplyError("Error: No edits provided");
|
|
188
|
+
}
|
|
189
|
+
const { bom, text } = stripBom(rawContent);
|
|
190
|
+
const lineEnding = detectLineEnding(text);
|
|
191
|
+
const normalizedOriginal = normalizeToLF(text);
|
|
192
|
+
const normalizedEdits = edits.map((edit) => ({
|
|
193
|
+
oldText: normalizeToLF(edit.oldText),
|
|
194
|
+
newText: normalizeToLF(edit.newText),
|
|
195
|
+
}));
|
|
196
|
+
const matches = normalizedEdits.map((edit, index) => matchEdit(normalizedOriginal, edit, index, normalizedEdits.length));
|
|
197
|
+
assertNoOverlaps(matches);
|
|
198
|
+
const byDescendingStart = [...matches].sort((a, b) => b.start - a.start);
|
|
199
|
+
let normalizedNext = normalizedOriginal;
|
|
200
|
+
for (const match of byDescendingStart) {
|
|
201
|
+
const edit = normalizedEdits[match.editIndex];
|
|
202
|
+
normalizedNext = normalizedNext.slice(0, match.start) + edit.newText + normalizedNext.slice(match.end);
|
|
203
|
+
}
|
|
204
|
+
if (normalizedNext === normalizedOriginal) {
|
|
205
|
+
throw new EditApplyError([
|
|
206
|
+
"Error: No changes made. The replacement produced identical content.",
|
|
207
|
+
"",
|
|
208
|
+
"Common causes and how to escape:",
|
|
209
|
+
"- oldText and newText are byte-identical. Verify newText actually contains the intended change (a missing trailing char like turning '#ec489' into '#ec4899' is a frequent culprit).",
|
|
210
|
+
"- The file already contains newText. Re-read the file to confirm the current state before editing again.",
|
|
211
|
+
"- For wholesale rewrites, use the write tool with overwrite=true and the full new content instead.",
|
|
212
|
+
].join("\n"));
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
content: bom + restoreLineEndings(normalizedNext, lineEnding),
|
|
216
|
+
normalizedOriginal,
|
|
217
|
+
normalizedNext,
|
|
218
|
+
bom,
|
|
219
|
+
lineEnding,
|
|
220
|
+
matches,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
export function formatEditMatchNotes(matches) {
|
|
224
|
+
const normalizedCount = matches.filter((match) => match.mode !== "exact").length;
|
|
225
|
+
if (normalizedCount === 0)
|
|
226
|
+
return "";
|
|
227
|
+
return `\n\nNote: ${normalizedCount} edit${normalizedCount === 1 ? "" : "s"} applied using normalized line matching for whitespace/formatting differences.`;
|
|
228
|
+
}
|
package/dist/tools/edit.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { ApprovalController } from "../approval/types.js";
|
|
7
7
|
import type { ToolRegistryEntry } from "../types.js";
|
|
8
8
|
import { type LspService } from "../lsp/index.js";
|
|
9
|
+
import { type FileStateTracker } from "./file-state.js";
|
|
9
10
|
export interface EditArgs {
|
|
10
11
|
path: string;
|
|
11
12
|
edits: Array<{
|
|
@@ -13,4 +14,4 @@ export interface EditArgs {
|
|
|
13
14
|
newText: string;
|
|
14
15
|
}>;
|
|
15
16
|
}
|
|
16
|
-
export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService): ToolRegistryEntry;
|
|
17
|
+
export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
|