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
5 changes: 5 additions & 0 deletions .changeset/pause-goal-clock-on-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Exclude time spent with the session closed from goal time budgets.
5 changes: 5 additions & 0 deletions .changeset/remove-goal-time-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Remove the 24-hour limit on goal time budgets.
2 changes: 2 additions & 0 deletions docs/en/guides/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ A goal can stop in three ways:

Write stop conditions into the objective. `/goal` does not have a separate stop-limit flag.

Time budgets count only while the goal is active and its session is open. Closing the session saves the elapsed time and pauses the goal. After reopening the session, use `/goal resume` to continue with the remaining budget; time spent closed or paused does not count.

## Manage goals in the web UI

The web UI shows the current goal in a strip below the conversation. Select the strip to expand or collapse its details. When a token budget is configured, the header shows its progress; goals without a token budget do not show a progress bar.
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/guides/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进

停止条件需要写在目标本身里。`/goal` 没有单独用于描述停止限制的语法。

时间预算只在目标处于活跃状态且会话保持打开时计时。关闭会话会保存累计用时并暂停目标。重新打开会话后,使用 `/goal resume` 按剩余预算继续;会话关闭或目标暂停期间不计时。

## 在 Web 界面中管理目标

Web 界面会在对话下方显示当前目标条。点击目标条可以展开或收起详细信息。配置 token 预算时,标题栏会显示预算进度;没有配置 token 预算的目标不会显示进度条。
Expand Down
18 changes: 10 additions & 8 deletions packages/agent-core-v2/src/features/goal/goalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createDecorator, IInstantiationService } from '#/_base/di/instantiation
import { MutableDisposable, type IDisposable } from '#/_base/di/lifecycle';
import { abortError } from '#/_base/utils/abort';
import { isPlainRecord } from '#/_base/utils/canonical-args';
import type { AgentContext } from '#/agent/agentContext/agentContext';
import { IAgentReminderService } from '#/features/reminder/reminderService';
import {
AgentActorService,
Expand Down Expand Up @@ -47,7 +48,7 @@ import {
toKimiErrorPayload,
type KimiErrorPayload,
} from '#/errors';
import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
import { ISessionUsageService } from '#/session/usage/sessionUsage';
import { IEventDispatcher } from '#/state/eventDispatcher';
import type { ExecutableToolResult } from '#/tool/toolContract';
Expand Down Expand Up @@ -886,9 +887,6 @@ function settleWallClock(context: GoalOperationContext, state: GoalState): numbe
Math.max(0, context.runtime.get(IGoalDeadlineScheduler).now() - context.effects.liveWallClockStartedAt)
);
}
if (state.status === 'active' && state.wallClockResumedAt !== undefined) {
return state.wallClockMs + Math.max(0, Date.now() - state.wallClockResumedAt);
}
return state.wallClockMs;
}

Expand All @@ -899,9 +897,6 @@ function liveWallClockMs(context: GoalOperationContext, state: GoalState): numbe
Math.max(0, context.runtime.get(IGoalDeadlineScheduler).now() - context.effects.liveWallClockStartedAt)
);
}
if (state.status === 'active' && state.wallClockResumedAt !== undefined) {
return state.wallClockMs + Math.max(0, Date.now() - state.wallClockResumedAt);
}
return state.wallClockMs;
}

Expand Down Expand Up @@ -950,7 +945,7 @@ function wallClockDeadlineDelay(context: GoalOperationContext): number | undefin
budgetMs === undefined ||
context.effects.liveWallClockStartedAt === undefined
) return undefined;
return Math.max(0, budgetMs - liveWallClockMs(context, state));
return Math.min(2_147_483_647, Math.max(0, budgetMs - liveWallClockMs(context, state)));
}

function handleWallClockDeadline(context: GoalOperationContext): void {
Expand Down Expand Up @@ -1112,6 +1107,12 @@ function createGoalEffectHandlers(runtime: AgentActorContext<GoalRuntimeState>)
isWaitForEnabled: () => isWaitForAvailable(context),
},
normalize: () => { normalizeAfterReplay(context); },
closing: (agent: AgentContext) => {
if (agent !== runtime.agent) return;
const state = runtime.getState().goal;
if (state === null || state.status !== 'active') return;
applyLifecycle(context, state, 'paused', 'Paused after agent closed', 'runtime');
},
turnStarted: (event: TurnStarted) => { handleTurnLaunched(context, event.turnId, event.origin); },
usageRecorded: (usage: UsageRecordedContext) => {
if (usage.agent === runtime.agent) handleUsageRecorded(context, usage);
Expand Down Expand Up @@ -1187,6 +1188,7 @@ const goalEffects = fromCallback(({
});
const disposables: IDisposable[] = [deadline];
if (input.runtime.agent.agentId === MAIN_AGENT_ID) {
disposables.push(input.runtime.get(IAgentLifecycleService).onWillClose(handlers.closing));
disposables.push(new GoalInjection(handlers.injection, reminderOf(input.runtime)));
disposables.push(input.runtime.get(IEventBus).subscribe(TurnStarted, handlers.turnStarted));
disposables.push(input.runtime.get(ISessionUsageService).onDidRecord(handlers.usageRecorded));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ Do not invent limits. Do not call this for vague wording such as "spend some tim
If the user gives a compound time, convert it to one supported unit before calling this tool.
For example, "2 hours and 3 minutes" can be set as `value: 123, unit: "minutes"`.

A time budget must be between 1 second and 24 hours — the tool rejects anything shorter or
longer, telling the user it is not a reasonable goal budget. Turn and token budgets are not
bounded this way; they must be positive and are rounded to the nearest whole number (minimum 1).
A time budget must be at least 1 second and convert to a finite number of milliseconds.
There is no upper duration limit. Turn and token budgets must be positive and are rounded
to the nearest whole number (minimum 1).
Comment on lines +15 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Regenerate snapshots after changing the tool description

Changing this raw description changes both the serialized llm.tools_snapshot payload and its hash, but the exact inline snapshots still contain the old 24-hour wording in test/agent/loop/loop.test.ts:155 and test/tool/tool.test.ts:4324,4403. Any full run containing those tests will therefore fail; update the affected snapshot text and hashes alongside this change.

Useful? React with 👍 / 👎.


Supported units:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
} from './set-goal-budget';

const MIN_REASONABLE_TIME_BUDGET_MS = 1_000;
const MAX_REASONABLE_TIME_BUDGET_MS = 24 * 60 * 60 * 1000;

export class SetGoalBudgetTool implements ISetGoalBudgetTool {
declare readonly _serviceBrand: undefined;
Expand Down Expand Up @@ -118,7 +117,7 @@ function budgetLimitsFromInput(input: SetGoalBudgetToolInput): GoalBudgetLimits
const wallClockBudgetMs = Math.round(toMilliseconds(input.value, input.unit));
if (
wallClockBudgetMs < MIN_REASONABLE_TIME_BUDGET_MS ||
wallClockBudgetMs > MAX_REASONABLE_TIME_BUDGET_MS
!Number.isFinite(wallClockBudgetMs)
) {
return null;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts

Large diffs are not rendered by default.

56 changes: 55 additions & 1 deletion packages/agent-core-v2/test/features/goal/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type AfterStepContext,
} from '#/agent/loop/loop';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { IAgentSwarmService } from '#/features/swarm/agent/swarm';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import type { PermissionMode, PermissionPolicyResult } from '#/agent/permissionPolicy/types';
Expand Down Expand Up @@ -1709,7 +1710,7 @@ describe('goal error catalog metadata', () => {

describe('AgentGoalService API boundary', () => {
it('exposes only goal commands, queries, and observations', () => {
expect(Object.getOwnPropertyNames(AgentGoalService.prototype).sort()).toEqual([
expect(Object.getOwnPropertyNames(AgentGoalService.prototype).toSorted()).toEqual([
'cancelGoal',
'constructor',
'createGoal',
Expand Down Expand Up @@ -1889,6 +1890,59 @@ describe('goal pause classification on provider errors', () => {
});

describe('AgentGoalService hard wall-clock deadline', () => {
it('saves elapsed time on close and resumes only the remaining budget', async () => {
const clock = new ManualGoalDeadlineScheduler();
const persistence = new InMemoryWireRecordPersistence();
const ctx = createTestAgent(
appService(IGoalDeadlineScheduler, clock),
wireRecordPersistenceServices(persistence),
);
let restored: TestAgentContext | undefined;
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000);
try {
ctx.configure();
await ctx.restorePersisted();
const lifecycle = ctx.get(IAgentLifecycleService);
const agent = ctx.get(IAgentScopeContext).agentContext;
const goals = ctx.get(IAgentGoalService);
await goals.createGoal({ objective: 'finish bounded work' });
await goals.setBudgetLimits({ budgetLimits: { wallClockBudgetMs: 10_000 } });
clock.advanceBy(3_000);
await lifecycle.remove(agent);

now.mockReturnValue(100_000);
const restoredClock = new ManualGoalDeadlineScheduler();
restored = createTestAgent(appService(IGoalDeadlineScheduler, restoredClock));
restored.configure();
await restored.restore([...persistence.records]);
const resumedGoals = restored.get(IAgentGoalService);
expect(resumedGoals.getGoal().goal).toMatchObject({
status: 'paused',
wallClockMs: 3_000,
budget: { remainingWallClockMs: 7_000, overBudget: false },
});

restoredClock.advanceBy(50_000);
await resumedGoals.resumeGoal();
restoredClock.advanceBy(6_999);
expect(resumedGoals.getGoal().goal).toMatchObject({
status: 'active',
wallClockMs: 9_999,
budget: { remainingWallClockMs: 1, overBudget: false },
});
restoredClock.advanceBy(1);
expect(resumedGoals.getGoal().goal).toMatchObject({
status: 'blocked',
wallClockMs: 10_000,
budget: { remainingWallClockMs: 0, wallClockBudgetReached: true },
});
} finally {
now.mockRestore();
await restored?.dispose();
await ctx.dispose();
}
});

it('aborts an in-flight LLM request when the wall-clock budget expires', async () => {
const clock = new ManualGoalDeadlineScheduler();
const llm = blockingGenerate();
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/test/features/goal/goalOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { ISessionUsageService } from '#/session/usage/sessionUsage';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
Expand Down Expand Up @@ -116,6 +117,7 @@ function buildHost(key: string): GoalHost {
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
ix.set(IEventBus, new SyncDescriptor(EventBusService));
ix.stub(IAgentLoopService, createLoopStub());
ix.stub(IAgentLifecycleService, { onWillClose: Event.None } as IAgentLifecycleService);
ix.stub(ISessionUsageService, {
onDidRecord: Event.None,
} as unknown as ISessionUsageService);
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/test/harness/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,7 @@ export class AgentTestContext {
}

private async closeWire(): Promise<void> {
if (this.session.accessor.get(IAgentLifecycleService).get(this.agent.id) === undefined) return;
await this.wire.flush();
}

Expand Down
10 changes: 5 additions & 5 deletions packages/agent-core-v2/test/tool/tool.test.ts

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions packages/agent-core-v2/test/wire/resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@ describe('Agent resume', () => {
expect(ctx.context.get()).toHaveLength(0);
});

it('restores an envelope-less active interval into a budget-reached paused goal', async () => {
it('restores an envelope-less active goal without charging offline time', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(6_000);
const persistence = new RecordingAgentPersistence(
[
Expand Down Expand Up @@ -851,19 +851,19 @@ describe('Agent resume', () => {
const goal = ctx.get(IAgentGoalService).getGoal().goal;
expect(goal).toMatchObject({
status: 'paused',
wallClockMs: 7_000,
wallClockMs: 2_000,
budget: {
wallClockBudgetReached: true,
remainingWallClockMs: 0,
overBudget: true,
wallClockBudgetReached: false,
remainingWallClockMs: 4_000,
overBudget: false,
},
});
expect(persistence.appended).toEqual([
expect.objectContaining({
type: 'goal.update',
status: 'paused',
reason: 'Paused after agent resume',
wallClockMs: 7_000,
wallClockMs: 2_000,
}),
]);
expect(persistence.rewritten).toContainEqual(
Expand All @@ -879,7 +879,7 @@ describe('Agent resume', () => {
}
});

it('restores only post-checkpoint active time from a 1.3 wall-clock checkpoint', async () => {
it('restores persisted elapsed time from a 1.3 checkpoint without charging offline time', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(6_000);
const persistence = new RecordingAgentPersistence([
{
Expand Down Expand Up @@ -907,13 +907,13 @@ describe('Agent resume', () => {

expect(ctx.get(IAgentGoalService).getGoal().goal).toMatchObject({
status: 'paused',
wallClockMs: 5_000,
wallClockMs: 3_000,
});
expect(persistence.appended).toEqual([
expect.objectContaining({
type: 'goal.update',
status: 'paused',
wallClockMs: 5_000,
wallClockMs: 3_000,
}),
]);
expect(persistence.rewritten).toContainEqual(
Expand Down
Loading