Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion electron/kimi.js
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ function createStderrThinkingParser() {
}

/**
* @typedef {{ id: string, name: string, input: string, output: string | null, phase: "start" | "end" | "single", isError: boolean, images?: { mediaType: string, data: string }[] }} ToolEvent
* @typedef {{ id: string, name: string, input: string, output: string | null, phase: "start" | "end" | "single", isError: boolean, images?: { mediaType: string, data: string }[], subagent?: { description: string, agentType: string | null, background: boolean } | null }} ToolEvent
*/

/**
Expand All @@ -500,6 +500,34 @@ function outputAndImages(value) {
return { output: truncate(text, OUTPUT_TRUNCATE), images: harvested.images };
}

/**
* Subagent fields from kimi's Agent tool args, parsed BEFORE input is
* truncated (a long prompt would cut the JSON). Since 0.24.2 a
* run_in_background launch returns at once and `kimi -p` stays alive
* ("steer" print policy) until the task finishes and steers a new turn —
* stream-json emits no completion event, so the run's exit settles it.
* @param {string} input
* @returns {{ description: string, agentType: string | null, background: boolean } | null}
*/
function agentCallInfo(input) {
let args;
try {
args = JSON.parse(input);
} catch {
return null;
}
if (!args || typeof args !== "object") return null;
return {
description:
typeof args.description === "string" && args.description
? args.description
: "Agent",
agentType:
typeof args.subagent_type === "string" ? args.subagent_type : null,
background: args.run_in_background === true,
};
}

/**
* All tool events carried by one stream line.
*
Expand Down Expand Up @@ -541,6 +569,7 @@ function extractToolEvents(obj) {
output: null,
phase: "start",
isError: false,
...(name === "Agent" ? { subagent: agentCallInfo(input) } : {}),
});
}
return out;
Expand Down
48 changes: 48 additions & 0 deletions electron/runner-provider-kimi.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ function createKimiRun(ctx) {
lastAssistantText,
markRunFailed,
appendDoneWorkLog,
addSubagentRow,
setSubagentStatus,
} = ctx;

/**
Expand Down Expand Up @@ -111,6 +113,30 @@ function createKimiRun(ctx) {
* @type {string | null}
*/
let capturedKimiSessionId = null;
/**
* Agent(run_in_background) tool ids launched by this run (#175). kimi -p
* stays alive until they finish (steer policy) and stream-json never
* reports a completion, so process exit is the only settle signal.
* @type {Set<string>}
*/
const backgroundSubagents = new Set();

/**
* CLI exit ends every agent it was running: "done" on a clean exit,
* "failed" when it died (#1443). stopRun already failed them on Stop.
* @param {"done" | "failed"} status
*/
function settleBackgroundSubagents(status) {
let changed = false;
for (const id of backgroundSubagents) {
changed = setSubagentStatus(threadId, id, status) || changed;
}
backgroundSubagents.clear();
if (!changed) return;
store.save();
pushDetail(threadId, null);
pushThreadsChanged();
}

const localCwd = thread.worktreePath || project.path;
const binary = resolveBin(providerEntry);
Expand Down Expand Up @@ -422,6 +448,15 @@ function createKimiRun(ctx) {
toolMeta,
);
toolMsgById.set(tool.id, msgId);
if (tool.subagent) {
addSubagentRow(threadId, {
id: tool.id,
description: tool.subagent.description,
agentType: tool.subagent.agentType,
status: "running",
});
if (tool.subagent.background) backgroundSubagents.add(tool.id);
}
const notice = guardrailNotice(
tool.name,
tool.input,
Expand Down Expand Up @@ -470,6 +505,17 @@ function createKimiRun(ctx) {
});
noteToolSpan(threadId, runId, tool.id, tool.name, tool.isError);
}
// A background launch acks at once; its row stays running until
// the CLI exits (settleBackgroundSubagents). A failed launch or a
// foreground agent settles here.
if (tool.isError || !backgroundSubagents.has(tool.id)) {
backgroundSubagents.delete(tool.id);
setSubagentStatus(
threadId,
tool.id,
tool.isError ? "failed" : "done",
);
}
} else {
// single fire-and-complete
const toolMeta = {
Expand Down Expand Up @@ -500,6 +546,7 @@ function createKimiRun(ctx) {
clearTimeout(pushTimer);
pushTimer = null;
}
settleBackgroundSubagents(code === 0 ? "done" : "failed");
const e = active.get(threadId);
if (!e || e.stopping || e.runId !== runId) return;
if (e.kind !== "kimi") return;
Expand Down Expand Up @@ -561,6 +608,7 @@ function createKimiRun(ctx) {
clearTimeout(pushTimer);
pushTimer = null;
}
settleBackgroundSubagents("failed");
const e = active.get(threadId);
if (!e || e.stopping || e.runId !== runId) return;
if (e.kind !== "kimi") return;
Expand Down
1 change: 1 addition & 0 deletions electron/runner-subagents.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ function createSubagents(ctx) {
}

return {
hasRunningSubagent,
addSubagentRow,
noteCursorSubagent,
setSubagentStatus,
Expand Down
4 changes: 4 additions & 0 deletions electron/runner-watchdogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function createWatchdogs(ctx) {
appendMessage,
pushDetail,
pushThreadsChanged,
hasRunningSubagent,
} = ctx;

/**
Expand All @@ -48,6 +49,9 @@ function createWatchdogs(ctx) {
if (thread.status !== "working") continue;
if (thread.awaitingInput) continue;
if (thread.stalledAt) continue;
// Background subagents run silently in the parent CLI (kimi -p stays
// alive for them, #175); "may be hung, Stop" would kill their results.
if (hasRunningSubagent(thread.id)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit the stall exemption to live background agents

After a crash or force-quit during a subagent, recoverInterruptedRuns marks the thread failed but leaves its persisted subagents rows as running, and startRun does not clear them. When that thread is run again, this unconditional check therefore suppresses stall detection for every subsequent turn even though the recorded subagent has no live process and can never settle. Settle stale rows during recovery or require this exemption to correspond to a currently active background agent.

Useful? React with 👍 / 👎.

const last = thread.lastEventAt ?? thread.runStartedAt ?? now;
if (now - last <= stallMs) continue;
store.updateThread(thread.id, { stalledAt: now });
Expand Down
2 changes: 2 additions & 0 deletions electron/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -2637,6 +2637,7 @@ function createRunner(opts) {
} = createTurnSetup(ctx);

const {
hasRunningSubagent,
addSubagentRow,
noteCursorSubagent,
setSubagentStatus,
Expand All @@ -2645,6 +2646,7 @@ function createRunner(opts) {
finishRunningSubagents,
} = createSubagents(ctx);
ctx.finishRunningSubagents = finishRunningSubagents;
ctx.hasRunningSubagent = hasRunningSubagent;

const {
sessionRecorder,
Expand Down
93 changes: 93 additions & 0 deletions electron/test/kimi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,45 @@ async function main() {
return;
}

if (scenario === "background-agent") {
// kimi >= 0.24.2 "steer" print policy (#175): the Agent launch acks at
// once, the process stays alive and silent while the task runs, then the
// completion steers a new main turn on the same stream.
emit({
role: "assistant",
tool_calls: [
{
type: "function",
id: "Agent:bg",
function: {
name: "Agent",
arguments: JSON.stringify({
prompt: "x".repeat(5000),
description: "survey repo",
subagent_type: "explore",
run_in_background: true,
}),
},
},
{
type: "function",
id: "Agent:fg",
function: {
name: "Agent",
arguments: JSON.stringify({ prompt: "p", description: "quick look" }),
},
},
],
});
emit({ role: "tool", tool_call_id: "Agent:bg", content: "task_id: t1 launched" });
emit({ role: "tool", tool_call_id: "Agent:fg", content: "done" });
emit({ role: "assistant", content: "launched" });
await delay(Number(process.env.CODER_FAKE_KIMI_BG_MS) || 600);
emit({ role: "assistant", content: "got: PONG" });
process.exit(0);
return;
}

process.stderr.write("unknown scenario\\n");
process.exit(1);
}
Expand Down Expand Up @@ -1291,6 +1330,60 @@ describe("kimi runner integration", () => {
);
});

it("background Agent rows stay running, skip the stall sweep, and settle at exit (#175)", async () => {
process.env.CODER_FAKE_KIMI_SCENARIO = "background-agent";
const prevStall = process.env.CODER_STALL_MS;
process.env.CODER_STALL_MS = "1";
try {
const thread = store.getThreads()[0];
await runner.startRun({ threadId: thread.id, prompt: "fan out" });
const row = (id) =>
(store.getThread(thread.id).subagents || []).find((r) => r.id === id);
await waitFor(() => row("Agent:fg")?.status === "done");

assert.deepEqual(row("Agent:bg"), {
id: "Agent:bg",
description: "survey repo",
agentType: "explore",
status: "running",
});
assert.equal(store.getThread(thread.id).status, "working");
store.updateThread(thread.id, { lastEventAt: Date.now() - 60_000 });
runner.checkStalls();
assert.equal(store.getThread(thread.id).stalledAt ?? null, null);

await waitFor(() => store.getThread(thread.id).status === "done");
assert.equal(row("Agent:bg").status, "done");
assert.ok(
store
.getMessages(thread.id)
.some((m) => m.role === "assistant" && /got: PONG/.test(m.text)),
);
} finally {
if (prevStall === undefined) delete process.env.CODER_STALL_MS;
else process.env.CODER_STALL_MS = prevStall;
}
});

it("stopRun settles running background Agent rows (#175)", async () => {
process.env.CODER_FAKE_KIMI_SCENARIO = "background-agent";
process.env.CODER_FAKE_KIMI_BG_MS = "60000";
try {
const thread = store.getThreads()[0];
await runner.startRun({ threadId: thread.id, prompt: "fan out" });
const row = () =>
(store.getThread(thread.id).subagents || []).find(
(r) => r.id === "Agent:bg",
);
await waitFor(() => row()?.status === "running");
await runner.stopRun({ threadId: thread.id });
// Killed work is failed, never done (#1443).
await waitFor(() => row().status === "failed");
} finally {
delete process.env.CODER_FAKE_KIMI_BG_MS;
}
});

it("stopRun kills kimi process and leaves idle", async () => {
process.env.CODER_FAKE_KIMI_SCENARIO = "slow";
const thread = store.getThreads()[0];
Expand Down
Loading