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/reject-subagent-goals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Reject subagent goal requests consistently instead of starting goals they cannot finish.
7 changes: 7 additions & 0 deletions packages/agent-core-v2/src/agent/goal/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const GoalErrors = {
GOAL_STATUS_INVALID: 'goal.status_invalid',
GOAL_METADATA_RESERVED: 'goal.metadata_reserved',
GOAL_NOT_RESUMABLE: 'goal.not_resumable',
GOAL_UNSUPPORTED_AGENT: 'goal.unsupported_agent',
},
info: {
'goal.already_exists': {
Expand Down Expand Up @@ -57,6 +58,12 @@ export const GoalErrors = {
public: true,
action: 'Only paused goals can be resumed.',
},
'goal.unsupported_agent': {
title: 'Goals are unavailable for subagents',
retryable: false,
public: true,
action: 'Run goal lifecycle commands on the main agent.',
},
},
} as const satisfies ErrorDomain;

Expand Down
5 changes: 3 additions & 2 deletions packages/agent-core-v2/src/agent/goal/goal.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* `goal` domain (L4) — per-agent goal lifecycle contract.
* `goal` domain (L4) — main-agent goal lifecycle contract.
*
* Defines the commands and snapshots used to create, inspect, update, and clear
* the durable goal state. Bound at Agent scope.
* the durable goal state. Bound at Agent scope; subagent callers are rejected
* with `goal.unsupported_agent`.
*/
import { createDecorator } from "#/_base/di/instantiation";
import type {
Expand Down
43 changes: 38 additions & 5 deletions packages/agent-core-v2/src/agent/goal/goalService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* `goal` domain (L4) — `IAgentGoalService` implementation.
*
* Owns the per-agent goal lifecycle; persists the goal in the `wire`
* Owns the main-agent goal lifecycle; persists the goal in the `wire`
* `GoalModel` (`GoalState | null`) through the `goal.create` / `goal.update` /
* `goal.clear` Ops (`wire.dispatch`), reads it through `wire.getModel`,
* publishes `goal.updated` live to `IEventBus`, and forces a replayed `active`
Expand All @@ -18,10 +18,13 @@
* `contextInjector`, drives continuation turns by enqueueing `newTurn`
* `StepRequest`s onto `loop` (the continuation message materializes when the
* loop pops it), accounts live
* turn usage through `usage`, writes system reminders through
* `systemReminder`, registers model tools through `toolRegistry`, and reports
* telemetry through `telemetry`. Measures time and arms hard deadlines through
* `goal`'s App-scoped deadline scheduler. Bound at Agent scope.
* turn usage through `usage`, observes terminal goal tool results through
* `toolExecutor`, writes system reminders through `systemReminder`, reports
* telemetry through `telemetry`, and checks main-agent eligibility through
* `scopeContext`. Measures time and arms hard deadlines through `goal`'s
* App-scoped deadline scheduler. Bound at Agent scope.
* Subagent instances reject every goal command and do not install goal
* injection, accounting, budget, or continuation hooks.
*/

import { randomUUID } from 'node:crypto';
Expand All @@ -44,6 +47,7 @@ import {
import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection';
import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import type { ExecutableToolResult } from '#/tool/toolContract';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import type { ToolBeforeExecuteContext } from '#/agent/toolExecutor/toolHooks';
Expand Down Expand Up @@ -223,8 +227,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
@IAgentUsageService usageService: IAgentUsageService,
@IConfigService private readonly config: IConfigService,
@IGoalDeadlineScheduler private readonly deadlineScheduler: IGoalDeadlineScheduler,
@IAgentScopeContext private readonly agentContext: IAgentScopeContext,
) {
super();
if (!this.isSupportedAgent) return;
this._register(
new GoalInjection(
{
Expand Down Expand Up @@ -296,26 +302,43 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
);
}

private get isSupportedAgent(): boolean {
return this.agentContext.agentId === 'main';
}

private assertSupportedAgent(): void {
if (this.isSupportedAgent) return;
throw new Error2(
ErrorCodes.GOAL_UNSUPPORTED_AGENT,
'Goals are only supported by the main agent',
{ details: { agentId: this.agentContext.agentId } },
);
Comment thread
chengluyu marked this conversation as resolved.
}

private get goalState(): GoalState | null {
return this.wire.getModel(GoalModel) as GoalState | null;
}

getGoal(): GoalToolResult {
this.assertSupportedAgent();
const state = this.goalState;
return { goal: state === null ? null : this.toSnapshot(state) };
}

getActiveGoal(): GoalSnapshot | null {
this.assertSupportedAgent();
const state = this.goalState;
if (state === null || state.status !== 'active') return null;
return this.toSnapshot(state);
}

isGoalToolTarget(turnId: number, goalId: string): boolean {
this.assertSupportedAgent();
return this.goalTurnTargets.get(turnId) === goalId;
}

async createGoal(input: CreateGoalInput, actor: GoalActor = 'user'): Promise<GoalSnapshot> {
this.assertSupportedAgent();
const objective = this.validateObjective(input.objective);
this.prepareForGoalCreation(input.replace === true);
const wallClockResumedAt = Date.now();
Expand Down Expand Up @@ -362,6 +385,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
}

async pauseGoal(input: GoalReasonInput = {}, actor: GoalActor = 'user'): Promise<GoalSnapshot> {
this.assertSupportedAgent();
const state = this.requireState();
if (state.status === 'paused') return this.toSnapshot(state);
if (state.status !== 'active') {
Expand All @@ -377,12 +401,14 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
input: GoalReasonInput = {},
actor: GoalActor = 'runtime',
): Promise<GoalSnapshot | null> {
this.assertSupportedAgent();
const state = this.goalState;
if (state === null || state.status !== 'active') return null;
return this.applyLifecycle(state, 'paused', input.reason, actor);
}

async resumeGoal(input: ResumeGoalInput = {}, actor: GoalActor = 'user'): Promise<GoalSnapshot> {
this.assertSupportedAgent();
const state = this.requireState();
if (state.status === 'active') return this.toSnapshot(state);
if (state.status !== 'paused' && state.status !== 'blocked') {
Expand Down Expand Up @@ -412,6 +438,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
input: { readonly budgetLimits: GoalBudgetLimits },
actor: GoalActor = 'user',
): Promise<GoalSnapshot> {
this.assertSupportedAgent();
const state = this.requireState();
const budgetLimits = { ...state.budgetLimits, ...input.budgetLimits };
this.wire.dispatch(updateGoal({ budgetLimits }));
Expand All @@ -428,6 +455,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
}

async cancelGoal(_input: GoalReasonInput = {}, actor: GoalActor = 'user'): Promise<GoalSnapshot> {
this.assertSupportedAgent();
const state = this.requireState();
const snapshot = this.toSnapshot(state);
if (state.status === 'active' && this.liveTurnId !== undefined) {
Expand All @@ -447,6 +475,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
input: GoalReasonInput = {},
actor: GoalActor = 'runtime',
): Promise<GoalSnapshot | null> {
this.assertSupportedAgent();
const state = this.goalState;
if (state === null || state.status !== 'active') return null;
const snapshot = this.applyLifecycle(state, 'blocked', input.reason, actor, {
Expand All @@ -459,6 +488,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
input: GoalReasonInput = {},
actor: GoalActor = 'model',
): Promise<GoalSnapshot | null> {
this.assertSupportedAgent();
const state = this.goalState;
if (state === null || state.status !== 'active') return null;
this.dispatchCompletion(state, input.reason, actor);
Expand Down Expand Up @@ -491,10 +521,12 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
}

async pauseOnInterrupt(input: GoalReasonInput = {}): Promise<GoalSnapshot | null> {
this.assertSupportedAgent();
return this.pauseActiveGoal(input, 'user');
}

async recordTokenUsage(tokenDelta: number): Promise<GoalSnapshot | null> {
this.assertSupportedAgent();
return this.accountTokenUsage(tokenDelta);
}

Expand All @@ -508,6 +540,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
}

async incrementTurn(): Promise<GoalSnapshot | null> {
this.assertSupportedAgent();
return this.incrementGoalTurn();
}

Expand Down
78 changes: 78 additions & 0 deletions packages/agent-core-v2/test/agent/goal/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { type AgentGoalService } from '#/agent/goal/goalService';
import { UpdateGoalTool, UpdateGoalToolInputSchema } from '#/agent/goal/tools/update-goal';
import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Step, type Turn } from '#/agent/loop/loop';
import { MessageStepRequest } from '#/agent/loop/stepRequest';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import {
IAgentToolExecutorService,
type ToolExecutionResult,
Expand Down Expand Up @@ -1495,6 +1496,83 @@ describe('goal error catalog metadata', () => {
public: true,
action: 'Only paused goals can be resumed.',
});
expect(errorInfo('goal.unsupported_agent')).toEqual({
title: 'Goals are unavailable for subagents',
retryable: false,
public: true,
action: 'Run goal lifecycle commands on the main agent.',
});
});
});

describe('AgentGoalService agent eligibility', () => {
let ctx: TestAgentContext;

beforeEach(() => {
ctx = createTestAgent(
agentService(IAgentScopeContext, {
_serviceBrand: undefined,
agentId: 'sub-1',
scope: (subKey?: string) =>
subKey === undefined ? 'test/agents/sub-1' : `test/agents/sub-1/${subKey}`,
}),
);
});

afterEach(async () => {
await ctx.dispose();
});

it.each([
['getGoal', (goals: IAgentGoalService) => goals.getGoal()],
['isGoalToolTarget', (goals: IAgentGoalService) => goals.isGoalToolTarget(1, 'goal-1')],
['createGoal', (goals: IAgentGoalService) => goals.createGoal({ objective: 'work' })],
['pauseGoal', (goals: IAgentGoalService) => goals.pauseGoal()],
['resumeGoal', (goals: IAgentGoalService) => goals.resumeGoal()],
['setBudgetLimits', (goals: IAgentGoalService) =>
goals.setBudgetLimits({ budgetLimits: { turnBudget: 1 } })],
['cancelGoal', (goals: IAgentGoalService) => goals.cancelGoal()],
['markBlocked', (goals: IAgentGoalService) => goals.markBlocked()],
['markComplete', (goals: IAgentGoalService) => goals.markComplete()],
] as const)(
'%s rejects direct goal service access when the agent is a subagent',
async (_name, call) => {
const goals = ctx.get(IAgentGoalService);
await expect(Promise.resolve().then<unknown>(() => call(goals))).rejects.toMatchObject({
code: 'goal.unsupported_agent',
details: { agentId: 'sub-1' },
});
},
);

it.each([
['createGoal', () => ctx.rpc.createGoal({ objective: 'work' })],
['getGoal', () => ctx.rpc.getGoal({})],
['pauseGoal', () => ctx.rpc.pauseGoal({})],
['resumeGoal', () => ctx.rpc.resumeGoal({})],
['cancelGoal', () => ctx.rpc.cancelGoal({})],
] as const)(
'%s rejects subagent goal RPC access with the stable goal error',
async (_name, call) => {
await expect(call()).rejects.toMatchObject({
code: 'goal.unsupported_agent',
details: { agentId: 'sub-1' },
});
},
);

it('does not continue a previously persisted goal when the agent is a subagent', async () => {
await ctx.restore([
{ type: 'goal.create', goalId: 'legacy-subagent-goal', objective: 'work' },
]);
ctx.mockNextResponse({ type: 'text', text: 'Handled as one normal subagent turn.' });

await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] });
await ctx.untilTurnEnd();
await Promise.resolve();
await Promise.resolve();

expect(ctx.llmCalls).toHaveLength(1);
});
});

Expand Down
8 changes: 7 additions & 1 deletion packages/agent-core-v2/test/agent/goal/goalOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { GoalDeadlineSchedulerService } from '#/agent/goal/goalDeadlineScheduler
import { AgentGoalService } from '#/agent/goal/goalService';
import { GoalModel } from '#/agent/goal/goalOps';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentUsageService } from '#/agent/usage/usage';
Expand Down Expand Up @@ -123,11 +124,16 @@ function buildHost(key: string): {
ix.stub(IAgentToolExecutorService, createToolExecutorStub());
ix.stub(IConfigService, createConfigStub());
ix.set(IGoalDeadlineScheduler, new SyncDescriptor(GoalDeadlineSchedulerService));
ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService));
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
});
ix.stub(IAgentScopeContext, {
_serviceBrand: undefined,
agentId: 'main',
scope: () => 'wire/agents/main',
});
ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService));
return {
wire,
svc: ix.get(IAgentGoalService),
Expand Down
12 changes: 12 additions & 0 deletions packages/kap-server/src/transport/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import {
ErrorCodes,
IAgentGoalService,
IAgentLifecycleService,
ISessionLifecycleService,
Error2,
Expand Down Expand Up @@ -85,6 +86,17 @@ export async function resolveService(
if (id === undefined) {
throw new Error2(ErrorCodes.REQUEST_INVALID, `unknown service: ${serviceName}`);
}
if (
scopeKind === 'agent' &&
id === IAgentGoalService &&
params['agent_id'] !== MAIN_AGENT_ID
) {
throw new Error2(
ErrorCodes.GOAL_UNSUPPORTED_AGENT,
'Goals are only supported by the main agent',
{ details: { agentId: params['agent_id'] ?? '' } },
);
}
try {
return scope.accessor.get(id) as object;
} catch {
Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/src/transport/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const KIMI_TO_PROTOCOL: Record<string, ErrorCode> = {
[ErrorCodes.GOAL_NOT_RESUMABLE]: ErrorCode.GOAL_NOT_RESUMABLE,
[ErrorCodes.GOAL_OBJECTIVE_EMPTY]: ErrorCode.GOAL_OBJECTIVE_EMPTY,
[ErrorCodes.GOAL_OBJECTIVE_TOO_LONG]: ErrorCode.GOAL_OBJECTIVE_TOO_LONG,
[ErrorCodes.GOAL_UNSUPPORTED_AGENT]: ErrorCode.GOAL_UNSUPPORTED_AGENT,
// hostFs / storage codes → closest v1 wire equivalent (ENOTDIR collapses
// into path-not-found); codes without an equivalent fall back to 50001.
[ErrorCodes.OS_FS_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND,
Expand Down
20 changes: 20 additions & 0 deletions packages/kap-server/test/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ describe('server-v2 /api/v2 RPC', () => {
await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
}

async function createSubagent(sessionId: string, agentId: string): Promise<void> {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
if (session === undefined) throw new Error(`session ${sessionId} not found`);
await session.accessor.get(IAgentLifecycleService).create({ agentId });
}

// --- Core scope -----------------------------------------------------------

it('describes all channels via GET /api/v2/channels', async () => {
Expand Down Expand Up @@ -433,6 +439,20 @@ describe('server-v2 /api/v2 RPC', () => {
expect(duplicate.body.code).toBe(40913);
});

it('rejects reflected goal helper access for subagents', async () => {
const id = await createSession(home as string);
await createSubagent(id, 'sub-1');

const { body } = await call<null>(
'POST',
rpc('agent', IAgentGoalService, 'clearInternal', { sid: id, aid: 'sub-1' }),
'user',
);

expect(body.code).toBe(40920);
expect(body.msg).toBe('Goals are only supported by the main agent');
});

it('lists and installs plugins through RPC', async () => {
const pluginRoot = await mkdtemp(join(tmpdir(), 'server-v2-plugin-source-'));
try {
Expand Down
Loading
Loading