@mrace07/kairo 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/README.md +141 -0
- package/dist/application/coding-agent.d.ts +85 -0
- package/dist/application/coding-agent.js +765 -0
- package/dist/application/context-manager.d.ts +22 -0
- package/dist/application/context-manager.js +174 -0
- package/dist/application/context-selector.d.ts +11 -0
- package/dist/application/context-selector.js +74 -0
- package/dist/application/evaluated-agent.d.ts +7 -0
- package/dist/application/evaluated-agent.js +16 -0
- package/dist/application/evaluation-comparison.d.ts +34 -0
- package/dist/application/evaluation-comparison.js +91 -0
- package/dist/application/evaluation-harness.d.ts +19 -0
- package/dist/application/evaluation-harness.js +217 -0
- package/dist/application/failure-analyzer.d.ts +5 -0
- package/dist/application/failure-analyzer.js +37 -0
- package/dist/application/interaction-routing.d.ts +9 -0
- package/dist/application/interaction-routing.js +20 -0
- package/dist/application/live-evaluation.d.ts +8 -0
- package/dist/application/live-evaluation.js +185 -0
- package/dist/application/model-routing.d.ts +12 -0
- package/dist/application/model-routing.js +40 -0
- package/dist/application/model-system-instruction.d.ts +4 -0
- package/dist/application/model-system-instruction.js +4 -0
- package/dist/application/self-evaluation.d.ts +26 -0
- package/dist/application/self-evaluation.js +394 -0
- package/dist/application/task-metrics.d.ts +31 -0
- package/dist/application/task-metrics.js +42 -0
- package/dist/application/verification-planner.d.ts +12 -0
- package/dist/application/verification-planner.js +97 -0
- package/dist/domain/models.d.ts +247 -0
- package/dist/domain/models.js +1 -0
- package/dist/domain/ports.d.ts +87 -0
- package/dist/domain/ports.js +1 -0
- package/dist/domain/provider-error.d.ts +18 -0
- package/dist/domain/provider-error.js +17 -0
- package/dist/infrastructure/configuration/config.d.ts +24 -0
- package/dist/infrastructure/configuration/config.js +79 -0
- package/dist/infrastructure/filesystem/platform-paths.d.ts +8 -0
- package/dist/infrastructure/filesystem/platform-paths.js +18 -0
- package/dist/infrastructure/persistence/sqlite-session-store.d.ts +82 -0
- package/dist/infrastructure/persistence/sqlite-session-store.js +447 -0
- package/dist/infrastructure/providers/gemini-provider.d.ts +14 -0
- package/dist/infrastructure/providers/gemini-provider.js +90 -0
- package/dist/infrastructure/providers/groq-provider.d.ts +16 -0
- package/dist/infrastructure/providers/groq-provider.js +101 -0
- package/dist/infrastructure/providers/jev-safety-advisor.d.ts +18 -0
- package/dist/infrastructure/providers/jev-safety-advisor.js +95 -0
- package/dist/infrastructure/providers/mistral-provider.d.ts +15 -0
- package/dist/infrastructure/providers/mistral-provider.js +137 -0
- package/dist/infrastructure/providers/openrouter-provider.d.ts +15 -0
- package/dist/infrastructure/providers/openrouter-provider.js +104 -0
- package/dist/infrastructure/providers/provider-recovery.d.ts +10 -0
- package/dist/infrastructure/providers/provider-recovery.js +108 -0
- package/dist/infrastructure/providers/provider-registry.d.ts +22 -0
- package/dist/infrastructure/providers/provider-registry.js +67 -0
- package/dist/infrastructure/repository/repository-awareness.d.ts +12 -0
- package/dist/infrastructure/repository/repository-awareness.js +25 -0
- package/dist/infrastructure/repository/repository-profiler.d.ts +35 -0
- package/dist/infrastructure/repository/repository-profiler.js +498 -0
- package/dist/infrastructure/security/macos-keychain-store.d.ts +17 -0
- package/dist/infrastructure/security/macos-keychain-store.js +73 -0
- package/dist/infrastructure/tools/workspace-tools.d.ts +30 -0
- package/dist/infrastructure/tools/workspace-tools.js +321 -0
- package/dist/interface/cli/evaluation-comparison-report.d.ts +6 -0
- package/dist/interface/cli/evaluation-comparison-report.js +46 -0
- package/dist/interface/cli/evaluation-report.d.ts +14 -0
- package/dist/interface/cli/evaluation-report.js +122 -0
- package/dist/interface/cli/index.d.ts +2 -0
- package/dist/interface/cli/index.js +238 -0
- package/dist/interface/cli/provider-setup.d.ts +16 -0
- package/dist/interface/cli/provider-setup.js +86 -0
- package/dist/interface/cli/repl.d.ts +7 -0
- package/dist/interface/cli/repl.js +19 -0
- package/dist/interface/cli/task-trace.d.ts +7 -0
- package/dist/interface/cli/task-trace.js +48 -0
- package/dist/interface/cli/tui.d.ts +147 -0
- package/dist/interface/cli/tui.js +910 -0
- package/package.json +61 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { ContextManager } from "./context-manager.js";
|
|
2
|
+
import { FailureAnalyzer } from "./failure-analyzer.js";
|
|
3
|
+
import { VerificationPlanner } from "./verification-planner.js";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
|
+
import { conversationSystemInstruction } from "./model-system-instruction.js";
|
|
6
|
+
const MAX_MODEL_TURNS = 20;
|
|
7
|
+
const MAX_TOOL_CALLS = 40;
|
|
8
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
9
|
+
const MAX_IDENTICAL_CALLS = 2;
|
|
10
|
+
// A small cap prevents an agent from repeatedly editing a workspace without converging.
|
|
11
|
+
const MAX_REPAIR_ATTEMPTS = 2;
|
|
12
|
+
const MAX_PLANNING_MODEL_TURNS = 12;
|
|
13
|
+
const MAX_PLANNING_TOOL_CALLS = 24;
|
|
14
|
+
const planningTools = new Set(["list_files", "read_file", "read_file_range", "search_files"]);
|
|
15
|
+
export class CodingAgent {
|
|
16
|
+
provider;
|
|
17
|
+
store;
|
|
18
|
+
tools;
|
|
19
|
+
approval;
|
|
20
|
+
toolDefinitions;
|
|
21
|
+
modelSelection;
|
|
22
|
+
jev;
|
|
23
|
+
jevFeatures;
|
|
24
|
+
repositoryAwareness;
|
|
25
|
+
context;
|
|
26
|
+
failureAnalyzer = new FailureAnalyzer();
|
|
27
|
+
verificationPlanner = new VerificationPlanner();
|
|
28
|
+
/** Creates the coordinator with its model, durable state, tools, and approval boundary. */
|
|
29
|
+
constructor(provider, store, tools, approval, toolDefinitions, modelSelection, jev, jevFeatures = {
|
|
30
|
+
routing: true,
|
|
31
|
+
safety: true,
|
|
32
|
+
recovery: true,
|
|
33
|
+
autonomy: false,
|
|
34
|
+
}, repositoryAwareness) {
|
|
35
|
+
this.provider = provider;
|
|
36
|
+
this.store = store;
|
|
37
|
+
this.tools = tools;
|
|
38
|
+
this.approval = approval;
|
|
39
|
+
this.toolDefinitions = toolDefinitions;
|
|
40
|
+
this.modelSelection = modelSelection;
|
|
41
|
+
this.jev = jev;
|
|
42
|
+
this.jevFeatures = jevFeatures;
|
|
43
|
+
this.repositoryAwareness = repositoryAwareness;
|
|
44
|
+
this.context = new ContextManager(store);
|
|
45
|
+
}
|
|
46
|
+
/** Starts a new persisted task and drives it until it completes, pauses, or fails. */
|
|
47
|
+
async run(sessionId, input, onText) {
|
|
48
|
+
let task = this.store.startTask(sessionId, input);
|
|
49
|
+
if (this.jev && this.jevFeatures.routing) {
|
|
50
|
+
const decision = await this.jevDecision(task, "route", () => this.jev.route(this.jevTaskState(input)));
|
|
51
|
+
if (decision?.value === "plan") {
|
|
52
|
+
task = this.store.updateTask(task.id, { mode: "planning" });
|
|
53
|
+
onText("\n[Jev routed this request to a one-time read-only plan]\n");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await this.executeTask(task, input, onText);
|
|
57
|
+
}
|
|
58
|
+
/** Inspects a workspace and saves a structured plan without allowing any mutation. */
|
|
59
|
+
async plan(sessionId, input, onText) {
|
|
60
|
+
const task = this.store.startTask(sessionId, input, "planning");
|
|
61
|
+
await this.executeTask(task, input, onText);
|
|
62
|
+
}
|
|
63
|
+
/** Answers without creating a task, loading repository context, or allowing tools. */
|
|
64
|
+
async answer(sessionId, input, onText) {
|
|
65
|
+
this.save(sessionId, { role: "user", content: input, createdAt: Date.now() });
|
|
66
|
+
const result = await this.provider.stream([{ role: "user", content: input, createdAt: Date.now() }], onText, undefined, conversationSystemInstruction, false);
|
|
67
|
+
if (result.toolCalls.length)
|
|
68
|
+
throw new Error("A conversation response unexpectedly requested a tool.");
|
|
69
|
+
if (result.text)
|
|
70
|
+
this.save(sessionId, { role: "model", content: result.text, createdAt: Date.now() });
|
|
71
|
+
}
|
|
72
|
+
/** Continues the failed task with a replacement provider after a quota exhaustion. */
|
|
73
|
+
async retryAfterProviderQuota(sessionId, onText) {
|
|
74
|
+
const task = this.store.latestTask(sessionId);
|
|
75
|
+
if (!task || task.status !== "failed")
|
|
76
|
+
throw new Error("No provider-quota failure is available to continue.");
|
|
77
|
+
const resumed = this.store.updateTask(task.id, { status: "acting", error: undefined });
|
|
78
|
+
await this.executeTask(resumed, undefined, onText);
|
|
79
|
+
}
|
|
80
|
+
/** Restarts the latest unfinished task using its saved conversation and repair history. */
|
|
81
|
+
async resume(sessionId, onText) {
|
|
82
|
+
const task = this.store.latestTask(sessionId);
|
|
83
|
+
if (!task)
|
|
84
|
+
throw new Error("This session has no task to resume.");
|
|
85
|
+
if (task.status === "completed" || task.status === "planned" || task.status === "cancelled")
|
|
86
|
+
throw new Error(`Task is already ${task.status}. Start a new task instead.`);
|
|
87
|
+
const resumed = this.store.updateTask(task.id, {
|
|
88
|
+
status: "planning",
|
|
89
|
+
error: undefined,
|
|
90
|
+
});
|
|
91
|
+
this.save(sessionId, {
|
|
92
|
+
role: "user",
|
|
93
|
+
content: `Resume the interrupted task: ${resumed.prompt}. Inspect the recorded context, resolve unfinished work, and verify any prior changes.`,
|
|
94
|
+
createdAt: Date.now(),
|
|
95
|
+
});
|
|
96
|
+
await this.executeTask(resumed, undefined, onText);
|
|
97
|
+
}
|
|
98
|
+
/** Returns the newest task state for a session without changing it. */
|
|
99
|
+
status(sessionId) {
|
|
100
|
+
return this.store.latestTask(sessionId);
|
|
101
|
+
}
|
|
102
|
+
/** Marks the active task as cancelled so later model turns cannot continue it. */
|
|
103
|
+
cancel(sessionId) {
|
|
104
|
+
const task = this.store.latestTask(sessionId);
|
|
105
|
+
return (task &&
|
|
106
|
+
this.store.updateTask(task.id, {
|
|
107
|
+
status: "cancelled",
|
|
108
|
+
summary: "Cancelled by user.",
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
/** Saves a compact checkpoint for the current task's long conversation. */
|
|
112
|
+
compact(sessionId) {
|
|
113
|
+
const task = this.store.latestTask(sessionId);
|
|
114
|
+
return task && this.context.compact(sessionId, task);
|
|
115
|
+
}
|
|
116
|
+
/** Runs a user-requested verification command through the normal approval gate. */
|
|
117
|
+
async verify(sessionId, command, onText) {
|
|
118
|
+
let task = this.store.latestTask(sessionId);
|
|
119
|
+
if (!task)
|
|
120
|
+
throw new Error("Start a task before running verification.");
|
|
121
|
+
task = this.store.updateTask(task.id, {
|
|
122
|
+
status: "verifying",
|
|
123
|
+
verificationCommand: command,
|
|
124
|
+
verificationOutput: undefined,
|
|
125
|
+
verificationPassed: undefined,
|
|
126
|
+
verificationExitCode: undefined,
|
|
127
|
+
verificationDiscovered: this.isDiscoveredVerification(task.sessionId, command),
|
|
128
|
+
verificationSelection: this.verificationPlanner.selectionForCommand(this.store.repositorySnapshot(task.sessionId), command, "manual"),
|
|
129
|
+
});
|
|
130
|
+
this.recordVerificationSelection(task, task.verificationSelection);
|
|
131
|
+
const call = {
|
|
132
|
+
id: crypto.randomUUID(),
|
|
133
|
+
name: "run_command",
|
|
134
|
+
args: { command, verification: true },
|
|
135
|
+
};
|
|
136
|
+
const result = await this.executeTool(task, call, onText);
|
|
137
|
+
task = this.store.updateTask(task.id, {
|
|
138
|
+
verificationOutput: result.output,
|
|
139
|
+
verificationPassed: result.ok,
|
|
140
|
+
verificationExitCode: result.exitCode ?? null,
|
|
141
|
+
status: result.ok ? "completed" : "failed",
|
|
142
|
+
error: result.ok ? undefined : result.output,
|
|
143
|
+
});
|
|
144
|
+
onText(result.ok ? "\n[Verification passed]\n" : "\n[Verification failed]\n");
|
|
145
|
+
}
|
|
146
|
+
/** Executes bounded model and tool turns for one task. */
|
|
147
|
+
async executeTask(initialTask, initialInput, onText) {
|
|
148
|
+
let task = this.store.updateTask(initialTask.id, {
|
|
149
|
+
status: initialTask.mode === "planning" ? "planning" : "acting",
|
|
150
|
+
});
|
|
151
|
+
if (initialInput)
|
|
152
|
+
this.save(task.sessionId, {
|
|
153
|
+
role: "user",
|
|
154
|
+
content: initialInput,
|
|
155
|
+
createdAt: Date.now(),
|
|
156
|
+
});
|
|
157
|
+
const calls = new Map();
|
|
158
|
+
let toolCalls = 0;
|
|
159
|
+
let failures = 0;
|
|
160
|
+
try {
|
|
161
|
+
const maxTurns = task.mode === "planning" ? MAX_PLANNING_MODEL_TURNS : MAX_MODEL_TURNS;
|
|
162
|
+
const maxToolCalls = task.mode === "planning" ? MAX_PLANNING_TOOL_CALLS : MAX_TOOL_CALLS;
|
|
163
|
+
for (let turn = 0; turn < maxTurns; turn += 1) {
|
|
164
|
+
if (this.store.task(task.id)?.status === "cancelled") {
|
|
165
|
+
onText("\nTask cancelled.\n");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const result = await this.modelTurn(task, onText);
|
|
169
|
+
if (result.text)
|
|
170
|
+
this.save(task.sessionId, {
|
|
171
|
+
role: "model",
|
|
172
|
+
content: result.text,
|
|
173
|
+
createdAt: Date.now(),
|
|
174
|
+
});
|
|
175
|
+
if (!result.toolCalls.length) {
|
|
176
|
+
if (task.mode === "planning") {
|
|
177
|
+
// PLAN mode can also answer a greeting or conceptual question. A plan is
|
|
178
|
+
// durable only after submit_plan, but plain conversational text is not an
|
|
179
|
+
// agent failure and should not be presented as one.
|
|
180
|
+
this.finish(task);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const verification = await this.runRecommendedVerification(task, onText);
|
|
184
|
+
task = this.store.task(task.id);
|
|
185
|
+
if (verification === "ran") {
|
|
186
|
+
if (task.status === "failed") {
|
|
187
|
+
onText(`\nKairo couldn't complete this task: ${task.error}\n`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
failures = 0;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (verification === "denied")
|
|
194
|
+
return;
|
|
195
|
+
task = this.finish(task);
|
|
196
|
+
if (task.status === "verification_required")
|
|
197
|
+
onText("\nChanges were made but no successful verification command ran. Use `/verify <command>`.\n");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const call of result.toolCalls) {
|
|
201
|
+
toolCalls += 1;
|
|
202
|
+
if (toolCalls > maxToolCalls)
|
|
203
|
+
return this.fail(task, "Tool-call limit reached.", onText);
|
|
204
|
+
const fingerprint = `${call.name}:${JSON.stringify(call.args)}`;
|
|
205
|
+
const count = (calls.get(fingerprint) || 0) + 1;
|
|
206
|
+
calls.set(fingerprint, count);
|
|
207
|
+
if (count > MAX_IDENTICAL_CALLS)
|
|
208
|
+
return this.fail(task, `Repeated identical tool call blocked: ${call.name}.`, onText);
|
|
209
|
+
const outcome = await this.executeTool(task, call, onText);
|
|
210
|
+
task = this.store.task(task.id);
|
|
211
|
+
if (task.status === "planned" || task.status === "verification_required")
|
|
212
|
+
return;
|
|
213
|
+
if (task.status === "failed") {
|
|
214
|
+
onText(`\nKairo couldn't complete this task: ${task.error}\n`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
failures = outcome.ok ? 0 : failures + 1;
|
|
218
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES)
|
|
219
|
+
return this.fail(task, "Too many consecutive tool failures.", onText);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.fail(task, "Model-turn limit reached.", onText);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
this.fail(task, `Model error: ${error.message}`, onText);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Completes only tasks whose changed files have a successful verification result. */
|
|
230
|
+
finish(task) {
|
|
231
|
+
return this.store.updateTask(task.id, {
|
|
232
|
+
status: task.changedFiles.length && task.verificationPassed !== true
|
|
233
|
+
? "verification_required"
|
|
234
|
+
: "completed",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** Records a terminal task failure and makes the reason visible in the REPL. */
|
|
238
|
+
fail(task, error, onText) {
|
|
239
|
+
this.store.updateTask(task.id, { status: "failed", error });
|
|
240
|
+
onText(`\nKairo couldn't complete this task: ${error}\n`);
|
|
241
|
+
}
|
|
242
|
+
/** Appends a durable conversation message for later context reconstruction. */
|
|
243
|
+
save(session, message) {
|
|
244
|
+
this.store.addMessage(session, message);
|
|
245
|
+
}
|
|
246
|
+
/** Applies approval, executes one tool call, and persists its observable outcome. */
|
|
247
|
+
async executeTool(task, call, onText) {
|
|
248
|
+
this.event(task, {
|
|
249
|
+
kind: "tool_requested",
|
|
250
|
+
operationId: call.id,
|
|
251
|
+
name: call.name.slice(0, 120),
|
|
252
|
+
});
|
|
253
|
+
if (task.mode === "planning")
|
|
254
|
+
return this.executePlanningTool(task, call, onText);
|
|
255
|
+
return this.executeApprovedTool(task, call, onText);
|
|
256
|
+
}
|
|
257
|
+
/** Keeps planning tasks read-only and accepts their final artifact without a workspace call. */
|
|
258
|
+
async executePlanningTool(task, call, onText) {
|
|
259
|
+
if (call.name === "submit_plan") {
|
|
260
|
+
this.save(task.sessionId, {
|
|
261
|
+
role: "model",
|
|
262
|
+
content: JSON.stringify(call.args),
|
|
263
|
+
toolCallId: call.id,
|
|
264
|
+
toolName: call.name,
|
|
265
|
+
createdAt: Date.now(),
|
|
266
|
+
});
|
|
267
|
+
const plan = this.validatePlan(call.args);
|
|
268
|
+
if (!plan)
|
|
269
|
+
return this.record(task, call, false, "Invalid plan submission.", false);
|
|
270
|
+
this.event(task, { kind: "tool_started", operationId: call.id, name: call.name });
|
|
271
|
+
this.store.updateTask(task.id, { status: "planned", plan });
|
|
272
|
+
this.event(task, {
|
|
273
|
+
kind: "tool_finished",
|
|
274
|
+
operationId: call.id,
|
|
275
|
+
name: call.name,
|
|
276
|
+
outcome: "succeeded",
|
|
277
|
+
});
|
|
278
|
+
this.event(task, { kind: "plan_submitted", operationId: call.id, name: "plan" });
|
|
279
|
+
this.store.recordTool(task.sessionId, call.id, call.name, call.args, null, "Plan saved.");
|
|
280
|
+
this.save(task.sessionId, {
|
|
281
|
+
role: "tool",
|
|
282
|
+
content: "Plan saved.",
|
|
283
|
+
toolCallId: call.id,
|
|
284
|
+
toolName: call.name,
|
|
285
|
+
createdAt: Date.now(),
|
|
286
|
+
});
|
|
287
|
+
onText("\n[Plan saved]\n");
|
|
288
|
+
return { ok: true, output: "Plan saved." };
|
|
289
|
+
}
|
|
290
|
+
if (!planningTools.has(call.name))
|
|
291
|
+
return this.record(task, call, false, "Planning mode allows only repository reads and submit_plan; no edits or commands ran.", false);
|
|
292
|
+
return this.executeApprovedTool(task, call, onText);
|
|
293
|
+
}
|
|
294
|
+
/** Validates persisted plan data rather than trusting provider-produced tool arguments. */
|
|
295
|
+
validatePlan(args) {
|
|
296
|
+
const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim())
|
|
297
|
+
? value.map((item) => item.trim())
|
|
298
|
+
: undefined;
|
|
299
|
+
const goal = typeof args.goal === "string" && args.goal.trim() ? args.goal.trim() : undefined;
|
|
300
|
+
const assumptions = strings(args.assumptions);
|
|
301
|
+
const steps = strings(args.steps);
|
|
302
|
+
const risks = strings(args.risks);
|
|
303
|
+
const files = Array.isArray(args.files)
|
|
304
|
+
? args.files.map((file) => {
|
|
305
|
+
const value = file;
|
|
306
|
+
return {
|
|
307
|
+
path: typeof value.path === "string" ? value.path.trim() : "",
|
|
308
|
+
reason: typeof value.reason === "string" ? value.reason.trim() : "",
|
|
309
|
+
};
|
|
310
|
+
})
|
|
311
|
+
: undefined;
|
|
312
|
+
const verification = args.verification;
|
|
313
|
+
const command = typeof verification?.command === "string" ? verification.command.trim() : undefined;
|
|
314
|
+
const reason = typeof verification?.reason === "string" ? verification.reason.trim() : "";
|
|
315
|
+
if (!goal ||
|
|
316
|
+
!assumptions ||
|
|
317
|
+
!steps?.length ||
|
|
318
|
+
!risks ||
|
|
319
|
+
!files?.length ||
|
|
320
|
+
!reason ||
|
|
321
|
+
files.some((file) => !file.path ||
|
|
322
|
+
!file.reason ||
|
|
323
|
+
file.path.startsWith("/") ||
|
|
324
|
+
file.path.split("/").includes("..")))
|
|
325
|
+
return undefined;
|
|
326
|
+
return {
|
|
327
|
+
goal,
|
|
328
|
+
assumptions,
|
|
329
|
+
files,
|
|
330
|
+
steps,
|
|
331
|
+
verification: { ...(command ? { command } : {}), reason },
|
|
332
|
+
risks,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** Records model latency even when streaming fails; partial operations remain visible after restart. */
|
|
336
|
+
async modelTurn(task, onText) {
|
|
337
|
+
await this.repositoryAwareness?.ensureFresh(task.sessionId, this.tools.root);
|
|
338
|
+
const messages = await this.context.prepare(task.sessionId, task);
|
|
339
|
+
const operationId = crypto.randomUUID();
|
|
340
|
+
const modelName = this.modelSelection
|
|
341
|
+
? `${this.modelSelection.provider}/${this.modelSelection.model}`
|
|
342
|
+
: undefined;
|
|
343
|
+
this.event(task, { kind: "model_started", operationId, name: modelName });
|
|
344
|
+
const started = performance.now();
|
|
345
|
+
try {
|
|
346
|
+
const result = await this.provider.stream(messages, onText, (progress) => {
|
|
347
|
+
this.event(task, {
|
|
348
|
+
kind: progress.kind === "retry"
|
|
349
|
+
? "provider_retry"
|
|
350
|
+
: progress.kind === "retry_wait"
|
|
351
|
+
? "provider_retry_wait"
|
|
352
|
+
: "provider_exhausted",
|
|
353
|
+
operationId,
|
|
354
|
+
outcome: progress.category,
|
|
355
|
+
durationMs: progress.kind === "retry_wait" ? progress.delayMs : undefined,
|
|
356
|
+
});
|
|
357
|
+
if (progress.kind === "retry")
|
|
358
|
+
onText(`\n[${this.modelSelection?.provider ?? "provider"} ${progress.category}: retry ${progress.retry}/3 in ${(progress.delayMs / 1000).toFixed(1)}s]\n`);
|
|
359
|
+
if (progress.kind === "exhausted")
|
|
360
|
+
onText(`\n[${this.modelSelection?.provider ?? "provider"} ${progress.category}: stopped retrying after ${progress.retry} retries]\n`);
|
|
361
|
+
}, this.instruction(task));
|
|
362
|
+
this.event(task, {
|
|
363
|
+
kind: "model_finished",
|
|
364
|
+
operationId,
|
|
365
|
+
name: modelName,
|
|
366
|
+
outcome: "succeeded",
|
|
367
|
+
durationMs: performance.now() - started,
|
|
368
|
+
});
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
this.event(task, {
|
|
373
|
+
kind: "model_finished",
|
|
374
|
+
operationId,
|
|
375
|
+
name: modelName,
|
|
376
|
+
outcome: "failed",
|
|
377
|
+
durationMs: performance.now() - started,
|
|
378
|
+
});
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/** Gives planning its own strict contract while preserving the existing implementation prompt. */
|
|
383
|
+
instruction(task) {
|
|
384
|
+
if (task.mode !== "planning")
|
|
385
|
+
return undefined;
|
|
386
|
+
return [
|
|
387
|
+
"You are Kairo in read-only planning mode.",
|
|
388
|
+
"For a request to change, investigate, or plan repository work, inspect only the files needed to make a concrete plan. You may use only list_files, read_file, read_file_range, and search_files.",
|
|
389
|
+
"For greetings or general questions that do not need repository context, answer directly without tools and do not submit a plan.",
|
|
390
|
+
"Never call write_file, edit_file, or run_command. Do not claim to have changed or verified anything.",
|
|
391
|
+
"When ready, call submit_plan with a concrete goal, assumptions, affected repository-relative files and reasons, ordered steps, a recommended verification command or no-command explanation, and risks.",
|
|
392
|
+
"For repository work, submit_plan is required to save the structured plan; do not end with prose alone.",
|
|
393
|
+
].join(" ");
|
|
394
|
+
}
|
|
395
|
+
/** Attaches identity and wall-clock time without retaining prompts or tool arguments. */
|
|
396
|
+
event(task, event) {
|
|
397
|
+
this.store.recordTaskEvent({ ...event, taskId: task.id, createdAt: Date.now() });
|
|
398
|
+
}
|
|
399
|
+
/** Executes a tool after user approval, except for the deliberately narrow Jev trust envelope. */
|
|
400
|
+
async executeApprovedTool(task, call, onText) {
|
|
401
|
+
const definition = this.toolDefinitions.find((item) => item.name === call.name);
|
|
402
|
+
const isVerification = call.name === "run_command" &&
|
|
403
|
+
(call.args.verification === true ||
|
|
404
|
+
this.isDiscoveredVerification(task.sessionId, String(call.args.command ?? "")));
|
|
405
|
+
this.save(task.sessionId, {
|
|
406
|
+
role: "model",
|
|
407
|
+
content: JSON.stringify(call.args),
|
|
408
|
+
toolCallId: call.id,
|
|
409
|
+
toolName: call.name,
|
|
410
|
+
createdAt: Date.now(),
|
|
411
|
+
});
|
|
412
|
+
let approved = null;
|
|
413
|
+
if (!definition)
|
|
414
|
+
return this.record(task, call, false, "Unknown tool requested.", false);
|
|
415
|
+
if (definition.mutating) {
|
|
416
|
+
if (await this.autonomouslyApprovedVerification(task, call, isVerification)) {
|
|
417
|
+
this.event(task, {
|
|
418
|
+
kind: "autonomous",
|
|
419
|
+
operationId: call.id,
|
|
420
|
+
name: call.name,
|
|
421
|
+
outcome: "jev-low-risk-discovered-verification",
|
|
422
|
+
});
|
|
423
|
+
onText(`\n[Jev autonomously approved discovered verification]\n`);
|
|
424
|
+
}
|
|
425
|
+
else if (this.taskScopedWritePath(task, call)) {
|
|
426
|
+
approved = true;
|
|
427
|
+
this.event(task, {
|
|
428
|
+
kind: "approval",
|
|
429
|
+
operationId: call.id,
|
|
430
|
+
outcome: "task-file-scope",
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
const description = await this.approvalDescription(task, call);
|
|
435
|
+
const approvalStarted = performance.now();
|
|
436
|
+
const decision = await this.approval.approve(call, description);
|
|
437
|
+
approved = decision !== false;
|
|
438
|
+
const scopedPath = this.normalizedWritePath(call);
|
|
439
|
+
if (decision === "task_file" && scopedPath) {
|
|
440
|
+
task = this.store.updateTask(task.id, {
|
|
441
|
+
approvedWritePaths: [...new Set([...task.approvedWritePaths, scopedPath])],
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
this.event(task, {
|
|
445
|
+
kind: "approval",
|
|
446
|
+
operationId: call.id,
|
|
447
|
+
outcome: approved ? "approved" : "denied",
|
|
448
|
+
durationMs: performance.now() - approvalStarted,
|
|
449
|
+
});
|
|
450
|
+
if (!approved) {
|
|
451
|
+
onText(`\n[Denied] ${call.name}\n`);
|
|
452
|
+
return this.record(task, call, false, "User denied this action.", false);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
onText(`\n[Tool] ${call.name}\n`);
|
|
457
|
+
this.event(task, { kind: "tool_started", operationId: call.id, name: call.name });
|
|
458
|
+
const started = performance.now();
|
|
459
|
+
let result;
|
|
460
|
+
try {
|
|
461
|
+
result = await this.tools.execute(call);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
this.event(task, {
|
|
465
|
+
kind: "tool_finished",
|
|
466
|
+
operationId: call.id,
|
|
467
|
+
name: call.name,
|
|
468
|
+
outcome: "failed",
|
|
469
|
+
durationMs: performance.now() - started,
|
|
470
|
+
});
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
this.event(task, {
|
|
474
|
+
kind: "tool_finished",
|
|
475
|
+
operationId: call.id,
|
|
476
|
+
name: call.name,
|
|
477
|
+
outcome: result.ok ? "succeeded" : "failed",
|
|
478
|
+
durationMs: performance.now() - started,
|
|
479
|
+
exitCode: result.exitCode,
|
|
480
|
+
});
|
|
481
|
+
if (isVerification && task.verificationSelection?.command !== String(call.args.command ?? "")) {
|
|
482
|
+
const selection = this.verificationPlanner.selectionForCommand(this.store.repositorySnapshot(task.sessionId), String(call.args.command ?? ""), "model");
|
|
483
|
+
task = this.store.updateTask(task.id, { verificationSelection: selection });
|
|
484
|
+
this.recordVerificationSelection(task, selection);
|
|
485
|
+
}
|
|
486
|
+
if (isVerification)
|
|
487
|
+
this.event(task, {
|
|
488
|
+
kind: "verification",
|
|
489
|
+
operationId: call.id,
|
|
490
|
+
outcome: result.ok ? "passed" : "failed",
|
|
491
|
+
exitCode: result.exitCode,
|
|
492
|
+
});
|
|
493
|
+
if (result.ok &&
|
|
494
|
+
(call.name === "write_file" || call.name === "edit_file") &&
|
|
495
|
+
typeof call.args.path === "string") {
|
|
496
|
+
const changedFiles = [...new Set([...task.changedFiles, call.args.path])];
|
|
497
|
+
this.store.updateTask(task.id, {
|
|
498
|
+
changedFiles,
|
|
499
|
+
verificationPassed: undefined,
|
|
500
|
+
verificationExitCode: undefined,
|
|
501
|
+
verificationOutput: undefined,
|
|
502
|
+
});
|
|
503
|
+
task = this.store.task(task.id);
|
|
504
|
+
}
|
|
505
|
+
if (isVerification)
|
|
506
|
+
this.store.updateTask(task.id, {
|
|
507
|
+
verificationCommand: String(call.args.command ?? ""),
|
|
508
|
+
verificationOutput: result.output,
|
|
509
|
+
verificationPassed: result.ok,
|
|
510
|
+
verificationExitCode: result.exitCode ?? null,
|
|
511
|
+
verificationDiscovered: this.isDiscoveredVerification(task.sessionId, String(call.args.command ?? "")),
|
|
512
|
+
});
|
|
513
|
+
if (isVerification && !result.ok && task.changedFiles.length) {
|
|
514
|
+
const attempts = this.store.repairAttempts(task.id);
|
|
515
|
+
const command = String(call.args.command ?? "");
|
|
516
|
+
const evidence = this.failureAnalyzer.analyze(command, result.output);
|
|
517
|
+
const decision = this.jev && this.jevFeatures.recovery
|
|
518
|
+
? await this.jevDecision(task, "recovery", () => this.jev.recover(this.jevRecoveryState(task, evidence)))
|
|
519
|
+
: undefined;
|
|
520
|
+
if (decision?.value === "escalate") {
|
|
521
|
+
this.store.updateTask(task.id, {
|
|
522
|
+
status: "verification_required",
|
|
523
|
+
error: "Jev recommends manual verification review before further repair.",
|
|
524
|
+
});
|
|
525
|
+
onText("\n[Jev escalated failed verification for manual review. Use /verify <command> when ready.]\n");
|
|
526
|
+
}
|
|
527
|
+
else if (decision?.value === "broaden") {
|
|
528
|
+
const profile = this.store.repositorySnapshot(task.sessionId);
|
|
529
|
+
const selection = profile && task.verificationSelection
|
|
530
|
+
? this.verificationPlanner.broader(profile, task.verificationSelection)
|
|
531
|
+
: undefined;
|
|
532
|
+
if (selection) {
|
|
533
|
+
task = this.store.updateTask(task.id, {
|
|
534
|
+
status: "verifying",
|
|
535
|
+
verificationSelection: selection,
|
|
536
|
+
});
|
|
537
|
+
this.recordVerificationSelection(task, selection);
|
|
538
|
+
onText(`\n[Jev recommends broader verification: ${selection.command}. Approval required before it runs.]\n`);
|
|
539
|
+
const broadened = await this.executeTool(task, {
|
|
540
|
+
id: crypto.randomUUID(),
|
|
541
|
+
name: "run_command",
|
|
542
|
+
args: { command: selection.command, verification: true },
|
|
543
|
+
}, onText);
|
|
544
|
+
if (broadened.output === "User denied this action.")
|
|
545
|
+
this.store.updateTask(task.id, { status: "verification_required" });
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
this.recordRepair(task, attempts.length, command, evidence, onText);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else if (attempts.length >= MAX_REPAIR_ATTEMPTS) {
|
|
552
|
+
this.store.updateTask(task.id, {
|
|
553
|
+
status: "failed",
|
|
554
|
+
error: `Repair limit reached after ${MAX_REPAIR_ATTEMPTS} failed verification attempts.`,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
this.recordRepair(task, attempts.length, command, evidence, onText);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
this.store.recordTool(task.sessionId, call.id, call.name, call.args, approved, result.output);
|
|
562
|
+
this.save(task.sessionId, {
|
|
563
|
+
role: "tool",
|
|
564
|
+
content: result.output,
|
|
565
|
+
toolCallId: call.id,
|
|
566
|
+
toolName: call.name,
|
|
567
|
+
createdAt: Date.now(),
|
|
568
|
+
});
|
|
569
|
+
return result;
|
|
570
|
+
}
|
|
571
|
+
/** Returns a normalized relative path only for scoped file writes inside the active workspace. */
|
|
572
|
+
normalizedWritePath(call) {
|
|
573
|
+
if (call.name !== "write_file" && call.name !== "edit_file")
|
|
574
|
+
return undefined;
|
|
575
|
+
if (typeof call.args.path !== "string" || !call.args.path.trim())
|
|
576
|
+
return undefined;
|
|
577
|
+
const absolute = resolve(this.tools.root, call.args.path);
|
|
578
|
+
const path = relative(this.tools.root, absolute);
|
|
579
|
+
return path && !path.startsWith("..") && !path.includes("../") ? path : undefined;
|
|
580
|
+
}
|
|
581
|
+
/** Checks a persisted, user-granted path scope without extending it to commands or other files. */
|
|
582
|
+
taskScopedWritePath(task, call) {
|
|
583
|
+
const path = this.normalizedWritePath(call);
|
|
584
|
+
return path && task.approvedWritePaths.includes(path) ? path : undefined;
|
|
585
|
+
}
|
|
586
|
+
/** Auto-authorizes only known verification after a high-confidence low-risk Jev assessment. */
|
|
587
|
+
async autonomouslyApprovedVerification(task, call, isVerification) {
|
|
588
|
+
if (!this.jev ||
|
|
589
|
+
!this.jevFeatures.safety ||
|
|
590
|
+
!this.jevFeatures.autonomy ||
|
|
591
|
+
call.name !== "run_command" ||
|
|
592
|
+
!isVerification ||
|
|
593
|
+
!this.isDiscoveredVerification(task.sessionId, String(call.args.command ?? "")))
|
|
594
|
+
return false;
|
|
595
|
+
const assessment = await this.jevAssessment(task, call);
|
|
596
|
+
return assessment?.value === "low";
|
|
597
|
+
}
|
|
598
|
+
/** Adds an advisory Jev label while preserving user approval outside the trust envelope. */
|
|
599
|
+
async approvalDescription(task, call) {
|
|
600
|
+
const description = this.tools.description(call);
|
|
601
|
+
const assessment = await this.jevAssessment(task, call);
|
|
602
|
+
return assessment
|
|
603
|
+
? `${description}\nJev risk: ${assessment.value} (${Math.round(assessment.confidence * 100)}% confidence).`
|
|
604
|
+
: this.jev && this.jevFeatures.safety
|
|
605
|
+
? `${description}\nJev risk: unavailable — standard approval required.`
|
|
606
|
+
: description;
|
|
607
|
+
}
|
|
608
|
+
/** Requests a typed risk assessment once; uncertain and failed assessments fall back safely. */
|
|
609
|
+
async jevAssessment(task, call) {
|
|
610
|
+
if (!this.jev || !this.jevFeatures.safety)
|
|
611
|
+
return undefined;
|
|
612
|
+
return this.jevDecision(task, "safety", async () => {
|
|
613
|
+
const result = await this.jev.assess(this.jevState(task, call));
|
|
614
|
+
return { value: result.risk, confidence: result.confidence };
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/** Records only decision metadata; low-confidence and failed decisions never change agent behavior. */
|
|
618
|
+
async jevDecision(task, name, decide) {
|
|
619
|
+
const operationId = crypto.randomUUID();
|
|
620
|
+
const started = performance.now();
|
|
621
|
+
this.event(task, { kind: "jev_requested", operationId, name });
|
|
622
|
+
try {
|
|
623
|
+
const decision = await decide();
|
|
624
|
+
const reliable = decision.confidence >= 0.85;
|
|
625
|
+
this.event(task, {
|
|
626
|
+
kind: "jev_completed",
|
|
627
|
+
operationId,
|
|
628
|
+
name,
|
|
629
|
+
outcome: `${decision.value}:${reliable ? "high-confidence" : "uncertain"}`,
|
|
630
|
+
durationMs: performance.now() - started,
|
|
631
|
+
});
|
|
632
|
+
return reliable ? decision : undefined;
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
this.event(task, {
|
|
636
|
+
kind: "jev_failed",
|
|
637
|
+
operationId,
|
|
638
|
+
name,
|
|
639
|
+
outcome: "unavailable",
|
|
640
|
+
durationMs: performance.now() - started,
|
|
641
|
+
});
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/** Builds bounded operation metadata without sending source, output, or secret-bearing edit content. */
|
|
646
|
+
jevState(task, call) {
|
|
647
|
+
const redact = (value) => value
|
|
648
|
+
.replace(/(?:sk|gsk|or|AIza)[-_a-zA-Z0-9]{12,}/g, "[redacted]")
|
|
649
|
+
.replace(/((?:api[_-]?key|token|password))\s*[=:]\s*\S+/gi, "$1=[redacted]")
|
|
650
|
+
.slice(0, 1_500);
|
|
651
|
+
const detail = call.name === "run_command"
|
|
652
|
+
? `command: ${redact(String(call.args.command ?? ""))}`
|
|
653
|
+
: `path: ${redact(String(call.args.path ?? "workspace"))}`;
|
|
654
|
+
return [
|
|
655
|
+
`Task: ${redact(task.prompt)}`,
|
|
656
|
+
`Tool: ${call.name}`,
|
|
657
|
+
detail,
|
|
658
|
+
"No source file contents, prior tool output, or credentials are included.",
|
|
659
|
+
].join("\n");
|
|
660
|
+
}
|
|
661
|
+
jevTaskState(input) {
|
|
662
|
+
return `Task request: ${input.replace(/(?:sk|gsk|or|AIza)[-_a-zA-Z0-9]{12,}/g, "[redacted]").slice(0, 1_500)}\nChoose whether Kairo should build directly or first create a read-only plan.`;
|
|
663
|
+
}
|
|
664
|
+
/** Supplies failure metadata only; provider output and source snippets remain local. */
|
|
665
|
+
jevRecoveryState(task, evidence) {
|
|
666
|
+
return [
|
|
667
|
+
`Changed paths: ${task.changedFiles.map((path) => path.slice(0, 240)).join(", ")}`,
|
|
668
|
+
`Verification scope: ${task.verificationSelection?.scope ?? "unknown"}`,
|
|
669
|
+
`Failure evidence: ${evidence.fileLocations.length ? "file locations extracted" : "no file locations extracted"}`,
|
|
670
|
+
`Affected paths: ${evidence.fileLocations.map((location) => location.path.slice(0, 240)).join(", ") || "unknown"}`,
|
|
671
|
+
"Choose repair, broader verification, or manual escalation. No source or command output is included.",
|
|
672
|
+
]
|
|
673
|
+
.join("\n")
|
|
674
|
+
.slice(0, 1_500);
|
|
675
|
+
}
|
|
676
|
+
recordRepair(task, previousAttempts, command, evidence, onText) {
|
|
677
|
+
this.store.recordRepairAttempt({
|
|
678
|
+
id: `repair-${crypto.randomUUID()}`,
|
|
679
|
+
taskId: task.id,
|
|
680
|
+
command,
|
|
681
|
+
evidence,
|
|
682
|
+
selectedFiles: evidence.fileLocations.map((location) => location.path),
|
|
683
|
+
createdAt: Date.now(),
|
|
684
|
+
});
|
|
685
|
+
onText(`\n[Verification failed — repair attempt ${previousAttempts + 1}/${MAX_REPAIR_ATTEMPTS}]\n`);
|
|
686
|
+
}
|
|
687
|
+
/** Persists a tool call that could not reach the executor, such as a denied request. */
|
|
688
|
+
record(task, call, ok, output, approved) {
|
|
689
|
+
this.event(task, {
|
|
690
|
+
kind: "tool_finished",
|
|
691
|
+
operationId: call.id,
|
|
692
|
+
name: call.name.slice(0, 120),
|
|
693
|
+
outcome: output === "User denied this action." ? "denied" : "rejected",
|
|
694
|
+
});
|
|
695
|
+
this.store.recordTool(task.sessionId, call.id, call.name, call.args, approved, output);
|
|
696
|
+
this.save(task.sessionId, {
|
|
697
|
+
role: "tool",
|
|
698
|
+
content: output,
|
|
699
|
+
toolCallId: call.id,
|
|
700
|
+
toolName: call.name,
|
|
701
|
+
createdAt: Date.now(),
|
|
702
|
+
});
|
|
703
|
+
return { ok, output };
|
|
704
|
+
}
|
|
705
|
+
/** Identifies commands suggested by the workspace's discovered verification scripts. */
|
|
706
|
+
isDiscoveredVerification(sessionId, command) {
|
|
707
|
+
return (this.store
|
|
708
|
+
.repositorySnapshot(sessionId)
|
|
709
|
+
?.verificationCandidates.some((candidate) => candidate.command === command) ?? false);
|
|
710
|
+
}
|
|
711
|
+
/** Recommends and runs one post-edit check through the ordinary approval gate. */
|
|
712
|
+
async runRecommendedVerification(task, onText) {
|
|
713
|
+
if (!task.changedFiles.length)
|
|
714
|
+
return "none";
|
|
715
|
+
const latestRepair = this.store.repairAttempts(task.id).at(-1);
|
|
716
|
+
const profile = this.store.repositorySnapshot(task.sessionId);
|
|
717
|
+
const selection = task.verificationPassed === true &&
|
|
718
|
+
task.verificationSelection?.label === "typecheck" &&
|
|
719
|
+
profile
|
|
720
|
+
? this.verificationPlanner.broader(profile, task.verificationSelection)
|
|
721
|
+
: latestRepair && task.verificationPassed !== true
|
|
722
|
+
? {
|
|
723
|
+
command: latestRepair.command,
|
|
724
|
+
label: "custom",
|
|
725
|
+
scope: task.verificationSelection?.scope ?? "broad",
|
|
726
|
+
reason: "Rerun the failed verification after a focused repair.",
|
|
727
|
+
source: "repair",
|
|
728
|
+
}
|
|
729
|
+
: task.verificationPassed !== true && profile
|
|
730
|
+
? this.verificationPlanner.select(profile, task.changedFiles)
|
|
731
|
+
: undefined;
|
|
732
|
+
if (!selection)
|
|
733
|
+
return "none";
|
|
734
|
+
task = this.store.updateTask(task.id, {
|
|
735
|
+
status: "verifying",
|
|
736
|
+
verificationSelection: selection,
|
|
737
|
+
verificationCommand: selection.command,
|
|
738
|
+
verificationOutput: undefined,
|
|
739
|
+
verificationPassed: undefined,
|
|
740
|
+
verificationExitCode: undefined,
|
|
741
|
+
verificationDiscovered: this.isDiscoveredVerification(task.sessionId, selection.command),
|
|
742
|
+
});
|
|
743
|
+
this.recordVerificationSelection(task, selection);
|
|
744
|
+
onText(`\n[Recommended ${selection.scope} verification: ${selection.command} — ${selection.reason}]\n`);
|
|
745
|
+
const result = await this.executeTool(task, {
|
|
746
|
+
id: crypto.randomUUID(),
|
|
747
|
+
name: "run_command",
|
|
748
|
+
args: { command: selection.command, verification: true },
|
|
749
|
+
}, onText);
|
|
750
|
+
if (result.output === "User denied this action.") {
|
|
751
|
+
this.store.updateTask(task.id, { status: "verification_required" });
|
|
752
|
+
onText("\n[Verification recommendation declined. Use /verify <command> when ready.]\n");
|
|
753
|
+
return "denied";
|
|
754
|
+
}
|
|
755
|
+
return "ran";
|
|
756
|
+
}
|
|
757
|
+
/** Stores selection metadata in the trace without command arguments or output. */
|
|
758
|
+
recordVerificationSelection(task, selection) {
|
|
759
|
+
this.event(task, {
|
|
760
|
+
kind: "verification_selected",
|
|
761
|
+
name: selection.label,
|
|
762
|
+
outcome: selection.scope,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|