ikie-cli 0.1.33 → 0.1.34
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 +26 -9
- package/dist/agent.d.ts +44 -0
- package/dist/agent.js +372 -120
- package/dist/config.d.ts +8 -0
- package/dist/config.js +4 -0
- package/dist/index.js +36 -1
- package/dist/mcp-manager.d.ts +75 -89
- package/dist/mcp-manager.js +710 -304
- package/dist/repl.js +297 -71
- package/dist/skills.d.ts +16 -0
- package/dist/skills.js +83 -6
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +21 -4
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +115 -166
- package/dist/tree.d.ts +19 -0
- package/dist/tree.js +266 -0
- package/package.json +2 -1
package/dist/agent.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import * as readline from 'node:readline';
|
|
3
|
-
import { TOOL_DEFS, SAFE_TOOLS, PLAN_TOOLS, formatToolArgs, executeTool, isRestrictedPath } from './tools.js';
|
|
3
|
+
import { TOOL_DEFS, SAFE_TOOLS, PLAN_TOOLS, formatToolArgs, executeTool, isRestrictedPath, getMcpToolDefs } from './tools.js';
|
|
4
4
|
import { IKIE_PORT } from './config.js';
|
|
5
5
|
import { renderMarkdown, extractThinkTags } from './renderer.js';
|
|
6
|
+
import { getSkill, renderSkill, mapAllowedTools } from './skills.js';
|
|
6
7
|
import { c, toolLine, toolSuccessLine, toolErrorLine, toolOutputBlock, toolDiffBlock, InlineSpinner, CH, toolMeta } from './theme.js';
|
|
8
|
+
/** Default per-turn step budget — guards against runaway tool loops. */
|
|
9
|
+
export const DEFAULT_MAX_STEPS = 60;
|
|
10
|
+
/** Result synthesized for a tool call that never produced one (cancel/crash). */
|
|
11
|
+
export const INTERRUPTED_TOOL_RESULT = 'Interrupted: this tool did not run to completion (the turn was cancelled).';
|
|
7
12
|
export function estimateTokens(chars) {
|
|
8
13
|
return Math.max(1, Math.round(chars / 4));
|
|
9
14
|
}
|
|
@@ -21,6 +26,59 @@ export function extractUpstreamError(err) {
|
|
|
21
26
|
e.message;
|
|
22
27
|
return upstream || 'Unknown error';
|
|
23
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Guarantee the OpenAI invariant: every `assistant` message that carries
|
|
31
|
+
* `tool_calls` is followed by a `tool` message for each call id. A turn that is
|
|
32
|
+
* cancelled (ESC/Ctrl-C), throws mid-stream, or is restored from a session saved
|
|
33
|
+
* mid-flight can leave "dangling" tool calls with no result — and the provider
|
|
34
|
+
* then rejects the *next* request ("an assistant message with 'tool_calls' must
|
|
35
|
+
* be followed by tool messages"). This splices a synthetic result for any
|
|
36
|
+
* unanswered call so history is always replayable. Pure and idempotent.
|
|
37
|
+
*/
|
|
38
|
+
export function repairDanglingToolCalls(messages) {
|
|
39
|
+
const answeredIds = new Set();
|
|
40
|
+
for (const m of messages) {
|
|
41
|
+
if (m.role === 'tool') {
|
|
42
|
+
const id = m.tool_call_id;
|
|
43
|
+
if (typeof id === 'string')
|
|
44
|
+
answeredIds.add(id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const m of messages) {
|
|
49
|
+
out.push(m);
|
|
50
|
+
if (m.role !== 'assistant')
|
|
51
|
+
continue;
|
|
52
|
+
const tcs = m.tool_calls;
|
|
53
|
+
if (!Array.isArray(tcs))
|
|
54
|
+
continue;
|
|
55
|
+
for (const tc of tcs) {
|
|
56
|
+
if (tc?.id && !answeredIds.has(tc.id)) {
|
|
57
|
+
out.push({ role: 'tool', tool_call_id: tc.id, content: INTERRUPTED_TOOL_RESULT });
|
|
58
|
+
answeredIds.add(tc.id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether the agent loop should make another model call. We continue purely on
|
|
66
|
+
* "there were tool calls to answer" — NOT on `finishReason`, because some
|
|
67
|
+
* providers send `finish_reason: 'stop'` (or null) alongside tool calls, which
|
|
68
|
+
* would otherwise silently drop the calls. `maxSteps` caps runaway loops.
|
|
69
|
+
*/
|
|
70
|
+
export function shouldContinue(toolCallCount, _finishReason, step, maxSteps) {
|
|
71
|
+
return toolCallCount > 0 && step < maxSteps;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Drop malformed tool calls (no function name) accumulated from a stream. An
|
|
75
|
+
* unnamed call can't be dispatched or answered, so keeping it would create an
|
|
76
|
+
* un-satisfiable `tool_calls` entry. Applied to both the dispatch list and the
|
|
77
|
+
* assistant message so they stay in lockstep.
|
|
78
|
+
*/
|
|
79
|
+
export function normalizeToolCalls(calls) {
|
|
80
|
+
return calls.filter(tc => typeof tc.name === 'string' && tc.name.trim().length > 0);
|
|
81
|
+
}
|
|
24
82
|
/**
|
|
25
83
|
* Safely restore previously-saved stdin listeners after a raw-mode interaction
|
|
26
84
|
* (permission prompt, ask_user, theme picker, agent turn).
|
|
@@ -45,6 +103,44 @@ const requestTimestamps = [];
|
|
|
45
103
|
function sleep(ms) {
|
|
46
104
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
47
105
|
}
|
|
106
|
+
/** Extract HTTP status from an OpenAI/fetch error. */
|
|
107
|
+
function extractStatus(err) {
|
|
108
|
+
const e = err;
|
|
109
|
+
if (typeof e.status === 'number')
|
|
110
|
+
return e.status;
|
|
111
|
+
if (e.response?.status)
|
|
112
|
+
return e.response.status;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Retry a function on transient failures (network errors, 5xx, 429) with
|
|
117
|
+
* exponential backoff + jitter. Does NOT retry 4xx (except 429).
|
|
118
|
+
*/
|
|
119
|
+
async function withRetry(fn, opts) {
|
|
120
|
+
const maxRetries = opts?.maxRetries ?? 2;
|
|
121
|
+
let lastErr;
|
|
122
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
123
|
+
if (attempt > 0) {
|
|
124
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000) + Math.random() * 1000;
|
|
125
|
+
if (opts?.label) {
|
|
126
|
+
process.stdout.write(`\n ${c.muted(`Retry ${attempt}/${maxRetries} for ${opts.label} in ${(delay / 1000).toFixed(1)}s`)}`);
|
|
127
|
+
}
|
|
128
|
+
await sleep(delay);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return await fn();
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
lastErr = err;
|
|
135
|
+
if (opts?.signal?.aborted)
|
|
136
|
+
throw err;
|
|
137
|
+
const status = extractStatus(err);
|
|
138
|
+
if (status && status >= 400 && status < 500 && status !== 429)
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw lastErr;
|
|
143
|
+
}
|
|
48
144
|
/**
|
|
49
145
|
* True when a bash command tries to kill/free processes by ikie's own host
|
|
50
146
|
* port — `kill $(lsof -ti:PORT)`, `fuser -k PORT/tcp`, `pkill ... PORT`, etc.
|
|
@@ -74,7 +170,7 @@ function toolPhaseLabel(name) {
|
|
|
74
170
|
case 'write_file': return 'Writing file';
|
|
75
171
|
case 'edit_file': return 'Editing file';
|
|
76
172
|
case 'read_file': return 'Reading';
|
|
77
|
-
case 'bash': return '
|
|
173
|
+
case 'bash': return 'Running command';
|
|
78
174
|
case 'spawn_agent': return 'Spawning agent';
|
|
79
175
|
case 'list_dir': return 'Listing directory';
|
|
80
176
|
case 'search_files': return 'Searching';
|
|
@@ -89,7 +185,15 @@ function toolPhaseLabel(name) {
|
|
|
89
185
|
case 'use_skill': return 'Loading skill';
|
|
90
186
|
case 'install_skill': return 'Installing skill';
|
|
91
187
|
case 'remove_skill': return 'Removing skill';
|
|
92
|
-
|
|
188
|
+
case 'mcp_list': return 'Listing MCP servers';
|
|
189
|
+
case 'mcp_add': return 'Adding MCP server';
|
|
190
|
+
default: {
|
|
191
|
+
if (name.startsWith('mcp__')) {
|
|
192
|
+
const parts = name.split('__');
|
|
193
|
+
return `MCP ${parts[1] ?? '?'}`;
|
|
194
|
+
}
|
|
195
|
+
return `Running ${name}`;
|
|
196
|
+
}
|
|
93
197
|
}
|
|
94
198
|
}
|
|
95
199
|
export class Agent {
|
|
@@ -119,7 +223,8 @@ export class Agent {
|
|
|
119
223
|
return this.conversation;
|
|
120
224
|
}
|
|
121
225
|
setConversation(messages) {
|
|
122
|
-
|
|
226
|
+
// Loaded sessions may have been saved mid-turn — repair before reuse.
|
|
227
|
+
this.conversation = repairDanglingToolCalls(messages);
|
|
123
228
|
}
|
|
124
229
|
getLastTurnStats() {
|
|
125
230
|
return { ...this.lastTurnStats };
|
|
@@ -143,7 +248,7 @@ export class Agent {
|
|
|
143
248
|
if (!this.conversation.length)
|
|
144
249
|
return { before: 0, after: 0 };
|
|
145
250
|
const before = this.estimateConversationTokens();
|
|
146
|
-
const res = await this.client.chat.completions.create({
|
|
251
|
+
const res = await withRetry(() => this.client.chat.completions.create({
|
|
147
252
|
model: this.config.model,
|
|
148
253
|
max_tokens: 4096,
|
|
149
254
|
messages: [
|
|
@@ -161,7 +266,7 @@ export class Agent {
|
|
|
161
266
|
+ 'This summary will replace the full conversation history.',
|
|
162
267
|
},
|
|
163
268
|
],
|
|
164
|
-
});
|
|
269
|
+
}), { label: 'compact' });
|
|
165
270
|
const summary = res.choices[0]?.message?.content ?? '(no summary)';
|
|
166
271
|
this.conversation = [
|
|
167
272
|
{
|
|
@@ -185,6 +290,9 @@ export class Agent {
|
|
|
185
290
|
async send(userMessage, opts = {}) {
|
|
186
291
|
this.activeTurnStats = { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
|
|
187
292
|
this.activeChangedFiles = new Set();
|
|
293
|
+
// A prior turn may have ended mid-tool-call (cancelled/errored). Heal the
|
|
294
|
+
// history before adding the new turn so the request is always valid.
|
|
295
|
+
this.conversation = repairDanglingToolCalls(this.conversation);
|
|
188
296
|
this.conversation.push({ role: 'user', content: userMessage });
|
|
189
297
|
try {
|
|
190
298
|
await this.runLoop(opts);
|
|
@@ -250,20 +358,67 @@ export class Agent {
|
|
|
250
358
|
}
|
|
251
359
|
// ── Main agent loop ───────────────────────────────────────────────────────
|
|
252
360
|
async runLoop(opts) {
|
|
361
|
+
const maxSteps = Math.max(1, opts.maxSteps ?? DEFAULT_MAX_STEPS);
|
|
362
|
+
let step = 0;
|
|
253
363
|
while (true) {
|
|
254
364
|
if (opts.signal?.aborted)
|
|
255
365
|
break;
|
|
366
|
+
step++;
|
|
256
367
|
const { assistantMsg, toolCalls, finishReason } = await this.callModel(opts);
|
|
257
368
|
this.conversation.push(assistantMsg);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (
|
|
369
|
+
// No tool calls → the model is done talking. Stop (regardless of how the
|
|
370
|
+
// provider labelled finish_reason).
|
|
371
|
+
if (!toolCalls.length)
|
|
261
372
|
break;
|
|
373
|
+
// Track which tool_call ids we've answered so we can guarantee the
|
|
374
|
+
// assistant message stays balanced even if the turn is cancelled partway.
|
|
375
|
+
const answered = new Set();
|
|
376
|
+
const pushResult = (id, content) => {
|
|
377
|
+
if (answered.has(id))
|
|
378
|
+
return;
|
|
379
|
+
answered.add(id);
|
|
380
|
+
this.conversation.push({ role: 'tool', tool_call_id: id, content });
|
|
381
|
+
};
|
|
262
382
|
const groups = this.groupToolCalls(toolCalls);
|
|
383
|
+
// ── Pass 1: all spawn_agents in parallel across groups ──────────────
|
|
384
|
+
const spawnResults = new Map(); // tool_call_id → result
|
|
385
|
+
{
|
|
386
|
+
const spawnTasks = [];
|
|
387
|
+
for (const group of groups) {
|
|
388
|
+
for (const tc of group) {
|
|
389
|
+
if (tc.name === 'spawn_agent') {
|
|
390
|
+
let input;
|
|
391
|
+
try {
|
|
392
|
+
input = JSON.parse(tc.argsStr || '{}');
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
input = {};
|
|
396
|
+
}
|
|
397
|
+
spawnTasks.push({ tc, input });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (spawnTasks.length > 0) {
|
|
402
|
+
if (this.activeTurnStats)
|
|
403
|
+
this.activeTurnStats.toolCalls += spawnTasks.length;
|
|
404
|
+
const results = await Promise.all(spawnTasks.map(st => this.runSubagent(st.input, opts)));
|
|
405
|
+
spawnTasks.forEach((st, i) => spawnResults.set(st.tc.id, results[i]));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// ── Pass 2: remaining groups in order ──────────────────────────────
|
|
263
409
|
for (const group of groups) {
|
|
264
410
|
if (opts.signal?.aborted)
|
|
265
411
|
break;
|
|
266
|
-
|
|
412
|
+
// Push spawn_agent results for any spawn calls in this group
|
|
413
|
+
for (const tc of group) {
|
|
414
|
+
const r = spawnResults.get(tc.id);
|
|
415
|
+
if (r !== undefined)
|
|
416
|
+
pushResult(tc.id, r);
|
|
417
|
+
}
|
|
418
|
+
const remaining = group.filter(tc => !spawnResults.has(tc.id));
|
|
419
|
+
if (remaining.length === 0)
|
|
420
|
+
continue;
|
|
421
|
+
const inputs = remaining.map(tc => {
|
|
267
422
|
try {
|
|
268
423
|
return JSON.parse(tc.argsStr || '{}');
|
|
269
424
|
}
|
|
@@ -273,64 +428,46 @@ export class Agent {
|
|
|
273
428
|
});
|
|
274
429
|
// Plan mode is read-only. The model normally isn't even offered mutating
|
|
275
430
|
// tools (see buildParams), but refuse here too as defense-in-depth.
|
|
276
|
-
if (this.mode === 'plan' && !PLAN_TOOLS.has(
|
|
431
|
+
if (this.mode === 'plan' && !PLAN_TOOLS.has(remaining[0].name)) {
|
|
277
432
|
if (this.activeTurnStats)
|
|
278
|
-
this.activeTurnStats.toolCalls +=
|
|
279
|
-
process.stdout.write(`\n${this.indent}${toolLine(
|
|
433
|
+
this.activeTurnStats.toolCalls += remaining.length;
|
|
434
|
+
process.stdout.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
|
|
280
435
|
process.stdout.write(`${this.indent}${toolErrorLine('blocked · plan mode is read-only')}\n`);
|
|
281
|
-
for (const tc of
|
|
282
|
-
|
|
283
|
-
role: 'tool', tool_call_id: tc.id,
|
|
284
|
-
content: 'Blocked: plan mode is read-only. Do not attempt changes — propose a plan instead. The user can switch to agent mode to apply it.',
|
|
285
|
-
});
|
|
436
|
+
for (const tc of remaining) {
|
|
437
|
+
pushResult(tc.id, 'Blocked: plan mode is read-only. Do not attempt changes — propose a plan instead. The user can switch to agent mode to apply it.');
|
|
286
438
|
}
|
|
287
439
|
continue;
|
|
288
440
|
}
|
|
289
|
-
if (
|
|
441
|
+
if (remaining.length === 1) {
|
|
290
442
|
if (this.activeTurnStats)
|
|
291
443
|
this.activeTurnStats.toolCalls++;
|
|
292
|
-
process.stdout.write(`\n${this.indent}${toolLine(
|
|
293
|
-
const result = await this.handleToolCall(
|
|
294
|
-
|
|
444
|
+
process.stdout.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
|
|
445
|
+
const result = await this.handleToolCall(remaining[0].name, remaining[0].id, inputs[0], opts);
|
|
446
|
+
pushResult(remaining[0].id, result);
|
|
295
447
|
}
|
|
296
448
|
else {
|
|
297
449
|
if (this.activeTurnStats)
|
|
298
|
-
this.activeTurnStats.toolCalls +=
|
|
299
|
-
const summary = this.formatGroupSummary(
|
|
300
|
-
process.stdout.write(`\n${this.indent}${toolLine(`${
|
|
301
|
-
if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(
|
|
302
|
-
const allowed = await this.checkPermission(
|
|
450
|
+
this.activeTurnStats.toolCalls += remaining.length;
|
|
451
|
+
const summary = this.formatGroupSummary(remaining[0].name, inputs);
|
|
452
|
+
process.stdout.write(`\n${this.indent}${toolLine(`${remaining[0].name} ×${remaining.length}`, summary).trimStart()}\n`);
|
|
453
|
+
if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(remaining[0].name) && remaining[0].name !== 'switch_mode') {
|
|
454
|
+
const allowed = await this.checkPermission(remaining[0].name, inputs[0]);
|
|
303
455
|
if (!allowed) {
|
|
304
|
-
for (const tc of
|
|
305
|
-
|
|
306
|
-
role: 'tool', tool_call_id: tc.id,
|
|
307
|
-
content: `Tool execution denied by user: ${tc.name}`,
|
|
308
|
-
});
|
|
456
|
+
for (const tc of remaining) {
|
|
457
|
+
pushResult(tc.id, `Tool execution denied by user: ${tc.name}`);
|
|
309
458
|
}
|
|
310
459
|
continue;
|
|
311
460
|
}
|
|
312
461
|
}
|
|
313
462
|
const t0 = Date.now();
|
|
314
463
|
let errors = 0;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const spawnIndices = [];
|
|
318
|
-
const otherIndices = [];
|
|
319
|
-
for (let i = 0; i < group.length; i++) {
|
|
320
|
-
if (group[i].name === 'spawn_agent')
|
|
321
|
-
spawnIndices.push(i);
|
|
322
|
-
else
|
|
323
|
-
otherIndices.push(i);
|
|
324
|
-
}
|
|
464
|
+
const groupSpinner = new InlineSpinner(`${toolPhaseLabel(remaining[0].name)} (${remaining.length} operations)`, t0);
|
|
465
|
+
groupSpinner.start();
|
|
325
466
|
const results = new Map();
|
|
326
|
-
|
|
327
|
-
const spawnResults = await Promise.all(spawnIndices.map(i => this.runSubagent(inputs[i], opts)));
|
|
328
|
-
spawnIndices.forEach((idx, i) => results.set(idx, spawnResults[i]));
|
|
329
|
-
}
|
|
330
|
-
for (const i of otherIndices) {
|
|
467
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
331
468
|
if (opts.signal?.aborted)
|
|
332
469
|
break;
|
|
333
|
-
const tc =
|
|
470
|
+
const tc = remaining[i];
|
|
334
471
|
try {
|
|
335
472
|
if (tc.name === 'read_file' && isRestrictedPath(String(inputs[i].path ?? ''))) {
|
|
336
473
|
const allowed = await this.checkPermission('read_file', inputs[i]);
|
|
@@ -339,7 +476,9 @@ export class Agent {
|
|
|
339
476
|
continue;
|
|
340
477
|
}
|
|
341
478
|
}
|
|
342
|
-
const result =
|
|
479
|
+
const result = tc.name === 'use_skill'
|
|
480
|
+
? await this.handleToolCall(tc.name, tc.id, inputs[i], opts)
|
|
481
|
+
: await executeTool(tc.name, inputs[i]);
|
|
343
482
|
if (result.startsWith('Error'))
|
|
344
483
|
errors++;
|
|
345
484
|
this.recordChangedFile(tc.name, inputs[i], result);
|
|
@@ -350,37 +489,102 @@ export class Agent {
|
|
|
350
489
|
results.set(i, `Tool error: ${err}`);
|
|
351
490
|
}
|
|
352
491
|
}
|
|
353
|
-
|
|
492
|
+
groupSpinner.stop();
|
|
493
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
354
494
|
const result = results.get(i);
|
|
355
|
-
if (result !== undefined)
|
|
356
|
-
|
|
357
|
-
}
|
|
495
|
+
if (result !== undefined)
|
|
496
|
+
pushResult(remaining[i].id, result);
|
|
358
497
|
}
|
|
359
498
|
const ms = Date.now() - t0;
|
|
360
499
|
const lineStr = errors === 0
|
|
361
|
-
? toolSuccessLine(ms, `${
|
|
362
|
-
: toolErrorLine(`${errors} of ${
|
|
500
|
+
? toolSuccessLine(ms, `${remaining.length} operations`)
|
|
501
|
+
: toolErrorLine(`${errors} of ${remaining.length} operations`);
|
|
363
502
|
process.stdout.write(`${this.indent}${lineStr}\n`);
|
|
364
503
|
}
|
|
365
504
|
}
|
|
505
|
+
// Invariant: balance the assistant message. Any tool_call that didn't get
|
|
506
|
+
// a result (aborted mid-group, an unexpected skip) is answered with a
|
|
507
|
+
// synthetic "interrupted" result so the next request is always valid.
|
|
508
|
+
for (const tc of toolCalls) {
|
|
509
|
+
if (!answered.has(tc.id))
|
|
510
|
+
pushResult(tc.id, INTERRUPTED_TOOL_RESULT);
|
|
511
|
+
}
|
|
512
|
+
if (opts.signal?.aborted)
|
|
513
|
+
break;
|
|
514
|
+
if (!shouldContinue(toolCalls.length, finishReason, step, maxSteps)) {
|
|
515
|
+
// Hit the step budget while still wanting tools — stop cleanly and ask
|
|
516
|
+
// for a final summary (with tools disabled, so it can't dangle).
|
|
517
|
+
await this.summarizeAndStop(opts, maxSteps);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
366
520
|
process.stdout.write('\n');
|
|
367
521
|
}
|
|
368
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Final wrap-up when the per-turn step budget is exhausted. One model call with
|
|
525
|
+
* tools disabled, so it produces a plain summary and can never leave a dangling
|
|
526
|
+
* tool call. Best-effort: failures here don't throw out of the turn.
|
|
527
|
+
*/
|
|
528
|
+
async summarizeAndStop(opts, maxSteps) {
|
|
529
|
+
process.stdout.write(`\n${this.indent}${c.warning('◔')} ${c.muted(`Reached step budget (${maxSteps}) — wrapping up.`)}\n`);
|
|
530
|
+
this.conversation.push({
|
|
531
|
+
role: 'user',
|
|
532
|
+
content: `[system] You have reached the ${maxSteps}-step tool budget for this turn. `
|
|
533
|
+
+ 'Stop calling tools now. Briefly summarize what you accomplished, what remains, '
|
|
534
|
+
+ 'and the exact next step to resume — no tool calls.',
|
|
535
|
+
});
|
|
536
|
+
try {
|
|
537
|
+
if (this.activeTurnStats)
|
|
538
|
+
this.activeTurnStats.modelCalls++;
|
|
539
|
+
await this.throttleModelRequest();
|
|
540
|
+
const params = this.buildParams();
|
|
541
|
+
const resp = await withRetry(() => this.client.chat.completions.create({
|
|
542
|
+
...params,
|
|
543
|
+
tools: undefined,
|
|
544
|
+
tool_choice: undefined,
|
|
545
|
+
}, (opts.signal ? { signal: opts.signal } : undefined)), { signal: opts.signal, label: 'final-summary' });
|
|
546
|
+
const text = resp.choices[0]?.message?.content ?? '';
|
|
547
|
+
this.conversation.push({ role: 'assistant', content: text || null });
|
|
548
|
+
if (text)
|
|
549
|
+
printResponse(text, this.indent);
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
if (!opts.signal?.aborted) {
|
|
553
|
+
process.stdout.write(`${this.indent}${toolErrorLine(extractUpstreamError(err))}\n`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
369
557
|
// ── Model calls ───────────────────────────────────────────────────────────
|
|
370
558
|
async callModel(opts) {
|
|
559
|
+
// Count + throttle exactly once per model turn (the non-streaming fallback
|
|
560
|
+
// below is part of the SAME turn, so it must not double-count or double-wait).
|
|
561
|
+
if (this.activeTurnStats)
|
|
562
|
+
this.activeTurnStats.modelCalls++;
|
|
563
|
+
await this.throttleModelRequest();
|
|
564
|
+
const state = { emitted: false };
|
|
371
565
|
try {
|
|
372
|
-
return await this.callModelStreaming(opts);
|
|
566
|
+
return await this.callModelStreaming(opts, state);
|
|
373
567
|
}
|
|
374
568
|
catch (err) {
|
|
375
569
|
if (opts.signal?.aborted)
|
|
376
570
|
throw err;
|
|
571
|
+
// Only fall back to non-streaming if the stream produced nothing yet.
|
|
572
|
+
// If it already streamed output then failed, replaying would double-print
|
|
573
|
+
// and double-bill — surface the error instead.
|
|
574
|
+
if (state.emitted)
|
|
575
|
+
throw err;
|
|
377
576
|
return await this.callModelNonStreaming(opts);
|
|
378
577
|
}
|
|
379
578
|
}
|
|
380
579
|
buildParams() {
|
|
580
|
+
// Always work on a copy — never push into the shared TOOL_DEFS array.
|
|
381
581
|
let tools = this.depth >= 1
|
|
382
582
|
? TOOL_DEFS.filter(t => t.function.name !== 'spawn_agent')
|
|
383
|
-
: TOOL_DEFS;
|
|
583
|
+
: [...TOOL_DEFS];
|
|
584
|
+
// Append first-class MCP tools in agent mode only (we can't prove they're read-only).
|
|
585
|
+
if (this.mode === 'agent' && this.depth === 0) {
|
|
586
|
+
tools = tools.concat(getMcpToolDefs());
|
|
587
|
+
}
|
|
384
588
|
// Always include switch_mode so the agent can request a mode change.
|
|
385
589
|
const switchModeTool = TOOL_DEFS.find(t => t.function.name === 'switch_mode');
|
|
386
590
|
// Plan mode: only offer read-only tools, and steer toward proposing a plan.
|
|
@@ -428,19 +632,16 @@ export class Agent {
|
|
|
428
632
|
}
|
|
429
633
|
requestTimestamps.push(Date.now());
|
|
430
634
|
}
|
|
431
|
-
async callModelStreaming(opts) {
|
|
635
|
+
async callModelStreaming(opts, state) {
|
|
432
636
|
const spinner = new InlineSpinner('Working', opts.startedAt);
|
|
433
637
|
spinner.start();
|
|
434
638
|
const requestOpts = opts.signal ? { signal: opts.signal } : undefined;
|
|
435
639
|
let stream;
|
|
436
640
|
try {
|
|
437
|
-
|
|
438
|
-
this.activeTurnStats.modelCalls++;
|
|
439
|
-
await this.throttleModelRequest();
|
|
440
|
-
stream = await this.client.chat.completions.create({
|
|
641
|
+
stream = await withRetry(() => this.client.chat.completions.create({
|
|
441
642
|
...this.buildParams(),
|
|
442
643
|
stream: true,
|
|
443
|
-
}, requestOpts);
|
|
644
|
+
}, requestOpts), { signal: opts.signal, label: 'stream' });
|
|
444
645
|
}
|
|
445
646
|
catch (err) {
|
|
446
647
|
spinner.stop();
|
|
@@ -470,8 +671,10 @@ export class Agent {
|
|
|
470
671
|
if (delta.content) {
|
|
471
672
|
textContent += delta.content;
|
|
472
673
|
phase = 'Generating';
|
|
674
|
+
state.emitted = true;
|
|
473
675
|
}
|
|
474
676
|
if (delta.tool_calls) {
|
|
677
|
+
state.emitted = true;
|
|
475
678
|
for (const tc of delta.tool_calls) {
|
|
476
679
|
const idx = tc.index;
|
|
477
680
|
if (!toolCallsMap.has(idx)) {
|
|
@@ -519,7 +722,7 @@ export class Agent {
|
|
|
519
722
|
if (textContent) {
|
|
520
723
|
printResponse(textContent, this.indent);
|
|
521
724
|
}
|
|
522
|
-
const toolCalls = [...toolCallsMap.values()];
|
|
725
|
+
const toolCalls = normalizeToolCalls([...toolCallsMap.values()]);
|
|
523
726
|
const assistantMsg = {
|
|
524
727
|
role: 'assistant',
|
|
525
728
|
content: textContent || null,
|
|
@@ -539,12 +742,9 @@ export class Agent {
|
|
|
539
742
|
const requestOpts = opts.signal ? { signal: opts.signal } : undefined;
|
|
540
743
|
let resp;
|
|
541
744
|
try {
|
|
542
|
-
|
|
543
|
-
this.activeTurnStats.modelCalls++;
|
|
544
|
-
await this.throttleModelRequest();
|
|
545
|
-
resp = await this.client.chat.completions.create({
|
|
745
|
+
resp = await withRetry(() => this.client.chat.completions.create({
|
|
546
746
|
...this.buildParams(),
|
|
547
|
-
}, requestOpts);
|
|
747
|
+
}, requestOpts), { signal: opts.signal, label: 'non-stream' });
|
|
548
748
|
}
|
|
549
749
|
catch (err) {
|
|
550
750
|
spinner.stop();
|
|
@@ -560,17 +760,19 @@ export class Agent {
|
|
|
560
760
|
if (textContent) {
|
|
561
761
|
printResponse(textContent, this.indent);
|
|
562
762
|
}
|
|
563
|
-
const toolCalls = (msg.tool_calls ?? []).map(tc => ({
|
|
763
|
+
const toolCalls = normalizeToolCalls((msg.tool_calls ?? []).map(tc => ({
|
|
564
764
|
id: tc.id, name: tc.function.name, argsStr: tc.function.arguments,
|
|
565
|
-
}));
|
|
765
|
+
})));
|
|
566
766
|
const assistantMsg = {
|
|
567
767
|
role: 'assistant',
|
|
568
768
|
content: textContent || null,
|
|
769
|
+
// Derive from the normalized list so the message and the dispatch list
|
|
770
|
+
// stay in lockstep (no dangling/un-dispatchable calls).
|
|
569
771
|
...(toolCalls.length ? {
|
|
570
|
-
tool_calls:
|
|
772
|
+
tool_calls: toolCalls.map(tc => ({
|
|
571
773
|
id: tc.id,
|
|
572
774
|
type: 'function',
|
|
573
|
-
function: tc.
|
|
775
|
+
function: { name: tc.name, arguments: tc.argsStr },
|
|
574
776
|
})),
|
|
575
777
|
} : {}),
|
|
576
778
|
};
|
|
@@ -581,6 +783,9 @@ export class Agent {
|
|
|
581
783
|
if (name === 'switch_mode') {
|
|
582
784
|
return this.handleSwitchMode(input);
|
|
583
785
|
}
|
|
786
|
+
if (name === 'use_skill') {
|
|
787
|
+
return this.handleUseSkill(input);
|
|
788
|
+
}
|
|
584
789
|
if (name === 'spawn_agent') {
|
|
585
790
|
return this.runSubagent(input, opts);
|
|
586
791
|
}
|
|
@@ -598,7 +803,7 @@ export class Agent {
|
|
|
598
803
|
return `Tool execution denied by user: read_file ${path}`;
|
|
599
804
|
}
|
|
600
805
|
}
|
|
601
|
-
if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(name)) {
|
|
806
|
+
if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(name) && !this.sessionAllowList.has(name)) {
|
|
602
807
|
// Self-kill safeguard: a bash command that kills/frees processes by ikie's
|
|
603
808
|
// own host port (e.g. `kill $(lsof -ti:3000)`) can match ikie's outbound
|
|
604
809
|
// socket and SIGTERM the session. Force a confirmation even if bash is
|
|
@@ -612,8 +817,17 @@ export class Agent {
|
|
|
612
817
|
return `Tool execution denied by user: ${name}`;
|
|
613
818
|
}
|
|
614
819
|
const t0 = Date.now();
|
|
820
|
+
const spinner = new InlineSpinner(toolPhaseLabel(name), t0);
|
|
821
|
+
// For bash commands with streaming, show spinner briefly then let output flow
|
|
822
|
+
const isStreamingBash = name === 'bash' && /\b(build|compile|test|deploy|install)\b/i.test(String(input.command ?? ''));
|
|
823
|
+
if (!isStreamingBash) {
|
|
824
|
+
spinner.start();
|
|
825
|
+
}
|
|
615
826
|
try {
|
|
616
827
|
const result = await executeTool(name, input);
|
|
828
|
+
if (!isStreamingBash) {
|
|
829
|
+
spinner.stop();
|
|
830
|
+
}
|
|
617
831
|
this.recordChangedFile(name, input, result);
|
|
618
832
|
const ms = Date.now() - t0;
|
|
619
833
|
let block;
|
|
@@ -626,15 +840,31 @@ export class Agent {
|
|
|
626
840
|
else {
|
|
627
841
|
block = toolOutputBlock(result, ms, this.indent);
|
|
628
842
|
}
|
|
629
|
-
|
|
843
|
+
if (block)
|
|
844
|
+
process.stdout.write(`${block}\n`);
|
|
630
845
|
return result;
|
|
631
846
|
}
|
|
632
847
|
catch (err) {
|
|
848
|
+
if (!isStreamingBash) {
|
|
849
|
+
spinner.stop();
|
|
850
|
+
}
|
|
633
851
|
const msg = err instanceof Error ? err.message : String(err);
|
|
634
852
|
process.stdout.write(`${this.indent}${toolErrorLine(msg)}\n`);
|
|
635
853
|
return msg;
|
|
636
854
|
}
|
|
637
855
|
}
|
|
856
|
+
async handleUseSkill(input) {
|
|
857
|
+
const name = (input.name ?? '').trim();
|
|
858
|
+
if (!name)
|
|
859
|
+
return 'Error: use_skill requires a name.';
|
|
860
|
+
const skill = getSkill(name);
|
|
861
|
+
if (!skill)
|
|
862
|
+
return `Error: no skill named "${name}".`;
|
|
863
|
+
for (const tool of mapAllowedTools(skill.allowedTools)) {
|
|
864
|
+
this.sessionAllowList.add(tool);
|
|
865
|
+
}
|
|
866
|
+
return renderSkill(skill);
|
|
867
|
+
}
|
|
638
868
|
async handleSwitchMode(input) {
|
|
639
869
|
if (this.depth > 0) {
|
|
640
870
|
return 'Error: subagents cannot switch mode. Return your findings and let the main agent decide.';
|
|
@@ -755,10 +985,44 @@ export class Agent {
|
|
|
755
985
|
process.stdout.write(`${this.indent}${c.error('✗')} ${c.muted('subagent failed')}\n`);
|
|
756
986
|
return `Subagent error: ${err instanceof Error ? err.message : String(err)}`;
|
|
757
987
|
}
|
|
758
|
-
|
|
988
|
+
let result = sub.getLastAssistantText();
|
|
989
|
+
// The subagent's reply IS the only thing returned to the parent. If it ended
|
|
990
|
+
// without a textual summary (e.g. stopped right after a tool call), ask once
|
|
991
|
+
// more for a self-contained summary so we never hand back an empty result.
|
|
992
|
+
if (!result && !opts.signal?.aborted) {
|
|
993
|
+
result = await sub.requestFinalSummary(opts.signal);
|
|
994
|
+
}
|
|
759
995
|
process.stdout.write(`${this.indent}${c.success('✓')} ${c.muted('subagent done')}\n\n`);
|
|
760
996
|
return result || '(subagent completed but produced no summary)';
|
|
761
997
|
}
|
|
998
|
+
/**
|
|
999
|
+
* Best-effort: one tool-less model call asking for a concise, self-contained
|
|
1000
|
+
* summary of the work so far. Used to guarantee a non-empty subagent result.
|
|
1001
|
+
*/
|
|
1002
|
+
async requestFinalSummary(signal) {
|
|
1003
|
+
this.conversation.push({
|
|
1004
|
+
role: 'user',
|
|
1005
|
+
content: 'Summarize what you did and any key results (paths changed, findings, answers) '
|
|
1006
|
+
+ 'in a few sentences. Do not call any tools.',
|
|
1007
|
+
});
|
|
1008
|
+
try {
|
|
1009
|
+
if (this.activeTurnStats)
|
|
1010
|
+
this.activeTurnStats.modelCalls++;
|
|
1011
|
+
await this.throttleModelRequest();
|
|
1012
|
+
const params = this.buildParams();
|
|
1013
|
+
const resp = await withRetry(() => this.client.chat.completions.create({
|
|
1014
|
+
...params,
|
|
1015
|
+
tools: undefined,
|
|
1016
|
+
tool_choice: undefined,
|
|
1017
|
+
}, (signal ? { signal } : undefined)), { signal, label: 'sub-summary' });
|
|
1018
|
+
const text = resp.choices[0]?.message?.content ?? '';
|
|
1019
|
+
this.conversation.push({ role: 'assistant', content: text || null });
|
|
1020
|
+
return text;
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
return '';
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
762
1026
|
getLastAssistantText() {
|
|
763
1027
|
for (let i = this.conversation.length - 1; i >= 0; i--) {
|
|
764
1028
|
const m = this.conversation[i];
|
|
@@ -782,25 +1046,12 @@ export class Agent {
|
|
|
782
1046
|
const t0 = Date.now();
|
|
783
1047
|
const preview = formatToolArgs(toolName, input);
|
|
784
1048
|
const { verb, tint } = toolMeta(toolName);
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
`${c.info.bold('a')} ${c.muted('always')} ` +
|
|
790
|
-
`${c.muted.bold('!')} ${c.muted('never')}\n` +
|
|
791
|
-
` ${c.muted(CH.arrow)} `;
|
|
792
|
-
process.stdout.write(makePrompt('0.0'));
|
|
793
|
-
const timerUpdate = () => {
|
|
794
|
-
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
795
|
-
process.stdout.write(`\x1b[3A\x1b[0J${makePrompt(elapsed)}`);
|
|
796
|
-
};
|
|
797
|
-
const timerInterval = setInterval(timerUpdate, 100);
|
|
798
|
-
const cleanup = () => {
|
|
799
|
-
clearInterval(timerInterval);
|
|
800
|
-
};
|
|
1049
|
+
const line = `\n ${tint('●')} ${c.white.bold('permission')} ${c.muted(`(${((Date.now() - t0) / 1000).toFixed(1)}s)`)} ${c.muted('·')} ${c.white(verb)} ${c.dim(preview)}\n`;
|
|
1050
|
+
process.stdout.write(line);
|
|
1051
|
+
const optionsStr = `${c.success(' y allow ')} ${c.error(' n deny ')} ${c.info(' a always ')} ${c.muted(' ! never ')}`;
|
|
1052
|
+
process.stdout.write(` ${optionsStr}\n ${c.muted(CH.arrow)} `);
|
|
801
1053
|
return new Promise((resolve) => {
|
|
802
1054
|
if (!process.stdin.isTTY) {
|
|
803
|
-
cleanup();
|
|
804
1055
|
process.stdout.write(chalk.dim('(non-interactive, denying)\n'));
|
|
805
1056
|
resolve(false);
|
|
806
1057
|
return;
|
|
@@ -815,34 +1066,35 @@ export class Agent {
|
|
|
815
1066
|
process.stdin.resume();
|
|
816
1067
|
}
|
|
817
1068
|
const onData = (data) => {
|
|
818
|
-
|
|
1069
|
+
const text = data.toString();
|
|
1070
|
+
const key = text.toLowerCase();
|
|
819
1071
|
process.stdin.removeListener('data', onData);
|
|
820
|
-
|
|
821
|
-
if (process.stdin.isTTY) {
|
|
1072
|
+
if (process.stdin.isTTY)
|
|
822
1073
|
process.stdin.setRawMode(wasRaw);
|
|
823
|
-
}
|
|
824
1074
|
restoreStdinListeners(savedDataListeners, savedKeypressListeners);
|
|
825
|
-
|
|
826
|
-
if (!savedDataListeners.length) {
|
|
1075
|
+
if (!savedDataListeners.length)
|
|
827
1076
|
process.stdin.pause();
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
process.stdout.write(
|
|
1077
|
+
let label;
|
|
1078
|
+
if (key === 'y') {
|
|
1079
|
+
label = c.success('allow');
|
|
1080
|
+
process.stdout.write(`${label}\n`);
|
|
832
1081
|
resolve(true);
|
|
833
1082
|
}
|
|
834
1083
|
else if (key === 'a') {
|
|
835
|
-
|
|
1084
|
+
label = c.info('always');
|
|
836
1085
|
this.sessionAllowList.add(toolName);
|
|
1086
|
+
process.stdout.write(`${label}\n`);
|
|
837
1087
|
resolve(true);
|
|
838
1088
|
}
|
|
839
1089
|
else if (key === '!') {
|
|
840
|
-
|
|
1090
|
+
label = c.muted('never');
|
|
841
1091
|
this.sessionDenyList.add(toolName);
|
|
1092
|
+
process.stdout.write(`${label}\n`);
|
|
842
1093
|
resolve(false);
|
|
843
1094
|
}
|
|
844
1095
|
else {
|
|
845
|
-
|
|
1096
|
+
label = c.error('deny');
|
|
1097
|
+
process.stdout.write(`${label}\n`);
|
|
846
1098
|
resolve(false);
|
|
847
1099
|
}
|
|
848
1100
|
};
|
|
@@ -1081,22 +1333,22 @@ only the non-obvious. Use \`ask_user\` only when truly blocked on a decision you
|
|
|
1081
1333
|
unsure. Don't ask for confirmation on safe operations.
|
|
1082
1334
|
|
|
1083
1335
|
## MCP (Model Context Protocol) System
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
-
|
|
1087
|
-
-
|
|
1088
|
-
|
|
1089
|
-
- \`
|
|
1090
|
-
- \`
|
|
1091
|
-
|
|
1336
|
+
MCP servers extend ikie with specialized capabilities like GitHub API, database access,
|
|
1337
|
+
browser automation, etc. When a server is configured, each of its tools appears as a
|
|
1338
|
+
first-class tool named \`mcp__<server>__<tool>\`. Call those tools directly — there is no
|
|
1339
|
+
meta-tool dance.
|
|
1340
|
+
|
|
1341
|
+
- \`mcp_list\`: List all configured MCP servers and their status/tools.
|
|
1342
|
+
- \`mcp_add\`: Add an MCP server by specifying the command directly (Claude/Cline-style).
|
|
1343
|
+
Example: \`mcp_add(name="magic", commandArgs="npx -y @21st-dev/magic@latest", env={API_KEY: "..."})\`.
|
|
1092
1344
|
|
|
1093
|
-
**Recognizing MCP config patterns:** When the user says things like "install this MCP",
|
|
1345
|
+
**Recognizing MCP config patterns:** When the user says things like "install this MCP",
|
|
1346
|
+
"claude mcp add", "add this MCP", or pastes a Claude-style MCP config, parse it and use
|
|
1347
|
+
\`mcp_add\`. The format is: \`<any-prefix> mcp add [--scope user|project|local] [--env KEY=VALUE]... [--header "H: v"]... [--transport stdio|http|sse] <name> [url | -- <command> args...]\`. Everything after \`--\` is the full command string. Translate this to \`mcp_add\` — do NOT try to run it as a bash command.
|
|
1094
1348
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
- **database**: Database operations for SQLite and PostgreSQL
|
|
1099
|
-
- **puppeteer**: Browser automation and web scraping
|
|
1349
|
+
You can also configure servers by creating a \`.mcp.json\` file in the project root (or
|
|
1350
|
+
\`.mcp.local.json\` for machine-local overrides, or \`~/.ikie/mcp.json\` for global ones).
|
|
1351
|
+
Each entry has shape \`{ "type": "stdio|http|sse", "command": "...", "args": [...], "env": {...}, "url": "...", "headers": {...}, "enabled": true, "autoStart": true }\`.
|
|
1100
1352
|
|
|
1101
1353
|
**When to use MCPs:** When you need specialized functionality beyond basic tools. For example:
|
|
1102
1354
|
- Use GitHub MCP to interact with repositories, issues, pull requests
|