Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/fix-goal-outcome-summaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix goal completion and blocked updates to produce one final user-facing outcome summary from the tool result.
5 changes: 5 additions & 0 deletions .changeset/fix-goal-queue-starts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix goal startup and queue handling so failed starts restore permission mode and queued goals wait behind new user messages.
5 changes: 5 additions & 0 deletions .changeset/fix-goal-token-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix goal token budgets to count model completion tokens and stop without extra continuation steps when the budget is exhausted.
5 changes: 5 additions & 0 deletions .changeset/keep-goal-tools-visible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep goal tools available to the main agent and return clear messages for invalid goal-control calls.
5 changes: 5 additions & 0 deletions .changeset/show-goal-hours.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show long-running goal wall-clock budget reminders in hours.
5 changes: 5 additions & 0 deletions .changeset/tighten-goal-mode-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Tighten goal-mode guidance so agents continue reasonable work across turns instead of ending goals prematurely.
13 changes: 11 additions & 2 deletions apps/kimi-code/src/tui/commands/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,10 +372,19 @@ async function startGoalWithPermission(
choice: GoalStartPermissionChoice,
options: GoalStartOptions,
): Promise<void> {
if (choice !== host.state.appState.permissionMode && (choice === 'auto' || choice === 'yolo')) {
const previousMode = host.state.appState.permissionMode;
const switched =
choice !== previousMode && (choice === 'auto' || choice === 'yolo');
if (switched) {
if (!(await setPermissionForGoal(host, choice))) return;
}
await startGoal(host, parsed, options);
const started = await startGoal(host, parsed, options);
// The permission switch only exists to run this goal. If creation fails
// (e.g. a goal already exists and `replace` was not given), restore the
// previous mode so the session is not left more permissive than before.
if (!started && switched) {
await setPermissionForGoal(host, previousMode);
}
}

async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise<boolean> {
Expand Down
9 changes: 7 additions & 2 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,8 @@ export class SessionEventHandler {
(session === undefined || this.host.session === session) &&
!this.host.aborted &&
this.host.state.appState.streamingPhase === 'idle' &&
this.host.state.queuedMessages.length === 0
this.host.state.queuedMessages.length === 0 &&
!this.host.state.queuedMessageDispatchPending
);
}

Expand Down Expand Up @@ -1003,14 +1004,18 @@ export class SessionEventHandler {
private finishCompaction(sendQueued: (item: QueuedMessage) => void): void {
const hasActiveTurn = this.host.streamingUI.hasActiveTurn();
if (!hasActiveTurn) {
const next = this.host.shiftQueuedMessage();
if (next !== undefined) {
this.host.state.queuedMessageDispatchPending = true;
}
this.host.setAppState({
isCompacting: false,
streamingPhase: 'idle',
});
this.host.resetLivePane();
const next = this.host.shiftQueuedMessage();
if (next !== undefined) {
setTimeout(() => {
this.host.state.queuedMessageDispatchPending = false;
sendQueued(next);
}, 0);
}
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,9 +560,15 @@ export class StreamingUIController {

const next = this.host.shiftQueuedMessage();
if (next !== undefined) {
// The message is out of the queue but not yet sent. Mark the dispatch
// pending *before* setAppState — that call synchronously retries
// queued-goal promotion, which would otherwise see an empty queue and an
// idle phase and start a goal ahead of this message.
state.queuedMessageDispatchPending = true;
this.host.setAppState({ streamingPhase: 'idle' });
this.host.resetLivePane();
setTimeout(() => {
state.queuedMessageDispatchPending = false;
sendQueued(next);
}, 0);
return;
Expand Down
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/tui-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export interface TUIState {
tasksBrowser: TasksBrowserState | undefined;
externalEditorRunning: boolean;
queuedMessages: QueuedMessage[];
/**
* True while a queued user message has been shifted out of
* {@link queuedMessages} but its deferred send has not run yet. The queue
* looks empty during this window, so queued-goal promotion must also check
* this flag to avoid starting a goal ahead of the user's earlier message.
*/
queuedMessageDispatchPending: boolean;
swarmModeEntry: 'manual' | 'task' | undefined;
}

Expand Down Expand Up @@ -103,6 +110,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
tasksBrowser: undefined,
externalEditorRunning: false,
queuedMessages: [],
queuedMessageDispatchPending: false,
swarmModeEntry: undefined,
};
}
19 changes: 19 additions & 0 deletions apps/kimi-code/test/tui/commands/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,25 @@ describe('handleGoalCommand', () => {
expect(manualHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' });
});

it('restores the previous permission mode when the goal fails to start', async () => {
const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' });
s.createGoal = vi.fn(async () => {
throw new KimiError(ErrorCodes.GOAL_ALREADY_EXISTS, 'A goal already exists');
});

await handleGoalCommand(manualHost, 'Ship feature X');
const picker = mountedPicker(manualHost);
picker.handleInput(DOWN);
picker.handleInput(ENTER);

await vi.waitFor(() => {
// Switched to YOLO to run the goal, then restored to Manual on failure.
expect(s.setPermission).toHaveBeenLastCalledWith('manual');
});
expect(s.setPermission).toHaveBeenCalledWith('yolo');
expect(manualHost.setAppState).toHaveBeenLastCalledWith({ permissionMode: 'manual' });
});

it('returns the command to the input box when a Manual-mode goal start is cancelled', async () => {
const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) {
permissionMode: 'auto',
},
queuedMessages: [],
queuedMessageDispatchPending: false,
theme: { palette: getBuiltInPalette('dark') },
toolOutputExpanded: false,
todoPanel: { getTodos: vi.fn(() => []) },
Expand All @@ -68,10 +69,14 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) {
flushNow: vi.fn(),
resetToolUi: vi.fn(),
finalizeTurn: vi.fn(),
hasActiveTurn: vi.fn(() => false),
hasThinkingDraft: vi.fn(() => false),
flushThinkingToTranscript: vi.fn(),
appendAssistantDelta: vi.fn(),
scheduleFlush: vi.fn(),
beginCompaction: vi.fn(),
endCompaction: vi.fn(),
cancelCompaction: vi.fn(),
},
requireSession: vi.fn(() => session),
setAppState: vi.fn(),
Expand Down Expand Up @@ -139,6 +144,20 @@ function turnEndedEvent() {
} as const;
}

function compactionCompletedEvent() {
return {
type: 'compaction.completed',
sessionId: 's1',
agentId: 'main',
result: {
summary: 'summary',
tokensBefore: 100,
tokensAfter: 10,
compactedCount: 1,
},
} as const;
}

function modelBlockedEvent() {
return {
type: 'goal.updated',
Expand Down Expand Up @@ -234,6 +253,76 @@ describe('SessionEventHandler goal queue promotion', () => {
expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' });
});

it('defers queued-goal promotion while a queued message is mid-dispatch', async () => {
const { host, session } = makeHost();
host.state.appState.streamingPhase = 'idle';
host.state.queuedMessages = [];
// The queue looks empty and the phase is idle, but a shifted queued message
// is still awaiting its deferred send. Promotion must not jump ahead of it.
host.state.queuedMessageDispatchPending = true;
const handler = new SessionEventHandler(host);

handler.requestQueuedGoalPromotion();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.createGoal).not.toHaveBeenCalled();

// Once the queued message has been dispatched, the flag clears and the
// promotion proceeds on the next retry.
host.state.queuedMessageDispatchPending = false;
handler.retryQueuedGoalPromotion();
await vi.waitFor(() => {
expect(session.createGoal).toHaveBeenCalledWith({
objective: 'Ship queued goal',
replace: false,
});
});
});

it('waits for a queued user input drained after compaction before promoting the next queued goal', async () => {
const { host, session } = makeHost();
host.state.appState.isCompacting = true;
host.state.queuedMessages = [{ text: 'queued user turn' }];
host.shiftQueuedMessage.mockImplementation(() => host.state.queuedMessages.shift());
const handler = new SessionEventHandler(host);
host.setAppState.mockImplementation((patch: Record<string, unknown>) => {
const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch;
Object.assign(host.state.appState, patch);
if (busyChanged) handler.retryQueuedGoalPromotion();
});
host.sendQueuedMessage.mockImplementation((_session: unknown, item: { text: string }) => {
if (item.text === 'queued user turn') {
host.setAppState({ streamingPhase: 'waiting' });
}
});
const sendQueued = sendQueuedViaHost(host, session);

handler.requestQueuedGoalPromotion();
handler.handleEvent(compactionCompletedEvent(), sendQueued);

await vi.waitFor(() => {
expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'queued user turn' });
});
expect(session.createGoal).not.toHaveBeenCalled();

handler.handleEvent(turnEndedEvent(), sendQueued);

await vi.waitFor(() => {
expect(session.createGoal).toHaveBeenCalledWith({
objective: 'Ship queued goal',
replace: false,
});
});
const sendQueuedCalls = host.sendQueuedMessage.mock.calls as Array<[unknown, { text?: string }]>;
const userMessageIndex = sendQueuedCalls.findIndex(
([, item]) => item.text === 'queued user turn',
);
expect(userMessageIndex).toBeGreaterThanOrEqual(0);
expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' });
const userMessageOrder = host.sendQueuedMessage.mock.invocationCallOrder[userMessageIndex]!;
const goalCreateOrder = session.createGoal.mock.invocationCallOrder[0]!;
expect(userMessageOrder).toBeLessThan(goalCreateOrder);
});

it('leaves the queued goal in place when the next goal cannot start', async () => {
const { host, session } = makeHost({ createGoalRejects: true });
const handler = new SessionEventHandler(host);
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-core/src/agent/goal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ import {
/** Maximum objective length in characters. */
const MAX_GOAL_OBJECTIVE_LENGTH = 4000;

/**
* Maximum completion-criterion length in characters. The criterion is repeated
* in every active/paused/blocked goal reminder, so an unbounded one would bloat
* both `state.json` and every continuation prompt. Unlike the objective (which
* is rejected when too long), this supplementary field is truncated so an
* over-long criterion never fails goal creation outright.
*/
const MAX_GOAL_COMPLETION_CRITERION_LENGTH = MAX_GOAL_OBJECTIVE_LENGTH;

const GOAL_CANCELLED_REMINDER = [
'The user cancelled the current goal.',
'Ignore earlier active-goal reminders for that goal.',
Expand Down Expand Up @@ -760,5 +769,8 @@ function budgetTelemetryProperties(limits: GoalBudgetLimits): TelemetryPropertie

function normalizeCompletionCriterion(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed?.length ? trimmed : undefined;
if (!trimmed?.length) return undefined;
return trimmed.length > MAX_GOAL_COMPLETION_CRITERION_LENGTH
? trimmed.slice(0, MAX_GOAL_COMPLETION_CRITERION_LENGTH)
: trimmed;
}
28 changes: 17 additions & 11 deletions packages/agent-core/src/agent/injection/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@
function buildBlockedNote(goal: GoalSnapshot): string {
const reason = goal.terminalReason;
const lines: string[] = [];
lines.push(
`There is a goal, currently blocked${reason ? ` (${reason})` : ''}. It is not being ` +
'pursued autonomously right now.',
);

Check warning on line 48 in packages/agent-core/src/agent/injection/goal.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-unicorn(no-immediate-mutation)

Do not call `push()` immediately after initializing an array.
lines.push('');
lines.push(`<untrusted_objective>\n${escapeUntrustedText(goal.objective)}\n</untrusted_objective>`);
if (goal.completionCriterion !== undefined) {
Expand Down Expand Up @@ -141,16 +141,20 @@
'Goal mode is iterative. Keep the self-audit brief each turn. Do not explore unrelated ' +
'interpretations once the goal can be decided. If the objective is simple, already answered, ' +
'impossible, unsafe, or contradictory, do not run another goal turn. Explain briefly if useful, ' +
'then call UpdateGoal with `complete` or `blocked` in the same turn. Otherwise, self-audit ' +
'against the objective and any completion criteria above, then do one coherent slice of work ' +
'toward the objective. Use multiple turns when the task naturally has multiple phases. Call ' +
'UpdateGoal with `complete` only when all required work is done, any stated validation has ' +
'passed, and there is no useful next action. Do not mark complete after only producing a plan, ' +
'summary, first pass, or partial result. If an external condition or required user input ' +
'prevents progress, or the objective cannot be completed as stated, call UpdateGoal with ' +
'`blocked`. Otherwise keep working — after your turn ends you will be prompted to continue. ' +
"Call UpdateGoal as soon as the goal is genuinely done or cannot proceed; don't keep going " +
'once there is nothing left to do.',
'then call UpdateGoal with `complete` or `blocked` in the same turn. Otherwise, choose one ' +
'bounded, useful slice of work toward the objective. Do not try to finish a broad goal in one ' +
'turn unless the whole goal is genuinely small. Most goal turns should not call UpdateGoal: ' +
'after completing a useful slice, if material work remains, end the turn normally without ' +
'calling UpdateGoal so the runtime can continue the goal in the next turn. Call UpdateGoal ' +
'with `complete` only when all required work is done, any stated validation has passed, and ' +
'there is no useful next action. Do not mark complete after only producing a plan, summary, ' +
'first pass, or partial result. Before calling `complete`, check that every required part of ' +
'the objective is done, completion criteria are satisfied, requested or expected validation ' +
'passed or has been reported as impossible, and no known material task remains. Call ' +
'UpdateGoal with `blocked` only for a genuine impasse: an external condition, required user ' +
'input, missing credentials or permissions, a persistent technical failure, or an impossible, ' +
'unsafe, or contradictory objective. Do not use `blocked` because the work is large, hard, ' +
'slow, uncertain, partial, still needs validation, or needs more goal turns.',
);
return lines.join('\n');
}
Expand Down Expand Up @@ -195,5 +199,7 @@
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m${seconds.toString().padStart(2, '0')}s`;
if (minutes < 60) return `${minutes}m${seconds.toString().padStart(2, '0')}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h${(minutes % 60).toString().padStart(2, '0')}m`;
}
6 changes: 0 additions & 6 deletions packages/agent-core/src/agent/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,14 +736,8 @@ export class ToolManager {
? enabledMcpNames
: enabledMcpNames.filter((name) => loadedSet.has(name));
const selectToolsName = disclosure ? [b.SELECT_TOOLS_TOOL_NAME] : [];
// Mutation goal tools are only offered to the model while a goal exists.
const hideGoalMutationTools = this.agent.goal.getGoal().goal === null;
return uniq([...this.enabledTools, ...selectToolsName, ...mcpNames])
.toSorted((a, b) => a.localeCompare(b))
.filter(
(name) =>
!(hideGoalMutationTools && (name === 'SetGoalBudget' || name === 'UpdateGoal')),
)
// select_tools is exposed exclusively through the disclosure gate — a
// profile or setActiveTools listing the name explicitly must not
// surface it in inline mode (it was silently dropped back when
Expand Down
Loading
Loading