Skip to content

Commit da597a0

Browse files
authored
Merge branch 'main' into feat/skill-group-selector
2 parents 615efd7 + 5c8df59 commit da597a0

17 files changed

Lines changed: 737 additions & 46 deletions

File tree

packages/agent-core-v2/src/agent/prompt/promptService.ts

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,29 @@ interface Record extends PromptSnapshot {
109109
handle: PromptHandle;
110110
}
111111

112+
function bundledSkillBlockCount(message: ContextMessage): number {
113+
return message.origin?.kind === 'user' ? (message.origin.skillActivations?.length ?? 0) : 0;
114+
}
115+
116+
function stripBundledSkillBlocks(message: ContextMessage): ContentPart[] {
117+
return message.content.slice(bundledSkillBlockCount(message));
118+
}
119+
120+
function mergeSteerMessages(records: readonly Record[]): ContextMessage {
121+
const skillActivations = records.flatMap((item) =>
122+
item.message.origin?.kind === 'user' ? (item.message.origin.skillActivations ?? []) : [],
123+
);
124+
return {
125+
role: 'user',
126+
content: [
127+
...records.flatMap((item) => item.message.content.slice(0, bundledSkillBlockCount(item.message))),
128+
...records.flatMap((item) => stripBundledSkillBlocks(item.message)),
129+
],
130+
toolCalls: [],
131+
origin: skillActivations.length === 0 ? USER_PROMPT_ORIGIN : { kind: 'user', skillActivations },
132+
};
133+
}
134+
112135
export const promptLaunchingKey = defineState<boolean>('prompt.launching', () => false);
113136

114137
export class AgentPromptService implements IAgentPromptService {
@@ -117,6 +140,7 @@ export class AgentPromptService implements IAgentPromptService {
117140
private readonly pending: Record[] = [];
118141
private readonly steered = new Map<string, Record[]>();
119142
private readonly reservedPromptIds = new Set<string>();
143+
private steering = 0;
120144
private fullCompactionService: IAgentFullCompactionService | undefined;
121145
readonly hooks = { onBeforeSubmitPrompt: new OrderedHookSlot<PromptSubmitContext>() };
122146

@@ -284,22 +308,41 @@ export class AgentPromptService implements IAgentPromptService {
284308
throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are not pending');
285309
}
286310
const selected = this.pending.filter((item) => ids.has(item.id));
287-
for (const item of selected) this.pending.splice(this.pending.indexOf(item), 1);
288-
const message: ContextMessage = {
289-
role: 'user', content: selected.flatMap((item) => item.message.content), toolCalls: [], origin: USER_PROMPT_ORIGIN,
290-
};
291-
const { message: rerouted, captions } = this.extractCompressionCaptions(message);
311+
const activeAtEntry = this.active;
312+
const { message: rerouted, captions } = this.extractCompressionCaptions(mergeSteerMessages(selected));
313+
await this.materializeDaemonRefs(rerouted);
314+
if (selected.some((item) => !this.pending.includes(item)) || this.active !== activeAtEntry) {
315+
throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are no longer pending');
316+
}
317+
this.steering++;
318+
const removed: { readonly item: Record; readonly index: number }[] = [];
319+
for (const item of selected) {
320+
const index = this.pending.indexOf(item);
321+
removed.push({ item, index });
322+
this.pending.splice(index, 1);
323+
}
292324
const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => {
293325
void this.dispatcher.dispatch(
294326
new TurnSteer({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN }),
295327
);
296328
}, () => {});
297-
const turn = (await this.loop.enqueue(request).assigned).turn;
298-
if (turn === undefined) throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'no active turn to steer into');
329+
let turn: Turn | undefined;
330+
try {
331+
turn = (await this.loop.enqueue(request).assigned).turn;
332+
} catch {
333+
turn = undefined;
334+
} finally {
335+
this.steering--;
336+
}
337+
if (turn === undefined || this.active !== activeAtEntry) {
338+
for (const { item, index } of removed.reverse()) this.pending.splice(index, 0, item);
339+
if (this.active === undefined) void this.startNext();
340+
throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, 'no active turn to steer into');
341+
}
299342
for (const item of selected) { item.state = 'steered'; item.launchedDeferred.resolve(turn); }
300343
this.steered.set(this.active.id, [...(this.steered.get(this.active.id) ?? []), ...selected]);
301344
void this.dispatcher.dispatch(
302-
new PromptSteered({ activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: rerouted.content as ContentPart[], steeredAt: new Date().toISOString() }),
345+
new PromptSteered({ activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: selected.flatMap((item) => stripBundledSkillBlocks(item.message)), steeredAt: new Date().toISOString() }),
303346
);
304347
return selected.map((item) => item.handle);
305348
}
@@ -322,6 +365,7 @@ export class AgentPromptService implements IAgentPromptService {
322365

323366
async inject(message: ContextMessage): Promise<Turn | undefined> {
324367
const { message: rerouted, captions } = this.extractCompressionCaptions(message);
368+
await this.materializeDaemonRefs(rerouted);
325369
const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => {
326370
void this.dispatcher.dispatch(
327371
new TurnSteer({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN }),
@@ -339,17 +383,13 @@ export class AgentPromptService implements IAgentPromptService {
339383
}
340384

341385
private async startNext(): Promise<void> {
342-
if (this.active !== undefined || this.launching) return;
386+
if (this.active !== undefined || this.launching || this.steering > 0) return;
343387
const item = this.pending.shift(); if (item === undefined) return;
344388
this.launching = true;
345389
try {
346390
if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { this.pending.unshift(item); return; }
347391
const { message, captions } = this.extractCompressionCaptions(item.message);
348-
if (message.content.some((part) => daemonFileRefFromPart(part) !== undefined)) {
349-
const files = this.instantiation.invokeFunction((accessor) => accessor.get(IFileService));
350-
const mediaStore = this.instantiation.invokeFunction((accessor) => accessor.get(ISessionMediaStore));
351-
await materializePromptDaemonRefs(message.content, { files, mediaStore });
352-
}
392+
await this.materializeDaemonRefs(message);
353393
if (await this.blockedByHook(message, false)) {
354394
this.appendPrompt(message, captions); item.state = 'blocked'; item.launchedDeferred.resolve(undefined);
355395
item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' });
@@ -381,6 +421,13 @@ export class AgentPromptService implements IAgentPromptService {
381421
void this.startNext();
382422
}
383423

424+
private async materializeDaemonRefs(message: ContextMessage): Promise<void> {
425+
if (!message.content.some((part) => daemonFileRefFromPart(part) !== undefined)) return;
426+
const files = this.instantiation.invokeFunction((accessor) => accessor.get(IFileService));
427+
const mediaStore = this.instantiation.invokeFunction((accessor) => accessor.get(ISessionMediaStore));
428+
await materializePromptDaemonRefs(message.content, { files, mediaStore });
429+
}
430+
384431
private async blockedByHook(promptMessage: ContextMessage, isSteer: boolean): Promise<boolean> {
385432
const ctx = { promptMessage, isSteer, block: false }; await this.hooks.onBeforeSubmitPrompt.run(ctx); return ctx.block;
386433
}
@@ -420,7 +467,7 @@ export class AgentPromptService implements IAgentPromptService {
420467
private publishCompleted(promptId: string, reason: 'completed' | 'failed' | 'blocked'): void { void this.dispatcher.dispatch(new PromptCompleted({ promptId, finishedAt: new Date().toISOString(), reason })); }
421468
private publishQueued(record: Record): void {
422469
if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return;
423-
void this.dispatcher.dispatch(new PromptQueued({ promptId: record.id, content: record.message.content, queueLength: this.pending.length }));
470+
void this.dispatcher.dispatch(new PromptQueued({ promptId: record.id, content: stripBundledSkillBlocks(record.message), queueLength: this.pending.length }));
424471
}
425472
private publishAborted(promptId: string): void { void this.dispatcher.dispatch(new PromptAborted({ promptId, abortedAt: new Date().toISOString() })); }
426473
}

packages/agent-core-v2/src/agent/skill/skill.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,18 @@ export interface PromptWithSkillsInput {
1919
readonly skills: readonly PromptSkillActivation[];
2020
}
2121

22+
export interface PromptWithSkillsResult {
23+
readonly turn_id?: number;
24+
readonly prompt_id: string;
25+
readonly created_at: string;
26+
readonly state: 'running' | 'queued' | 'blocked';
27+
}
28+
2229
export interface IAgentSkillService {
2330
readonly _serviceBrand: undefined;
2431

2532
activate(input: SkillActivationInput): Promise<PromptLaunchResult>;
26-
promptWithSkills(input: PromptWithSkillsInput): Promise<PromptLaunchResult | undefined>;
33+
promptWithSkills(input: PromptWithSkillsInput): Promise<PromptWithSkillsResult>;
2734
recordModelToolActivation(origin: SkillActivationOrigin): void;
2835
}
2936

packages/agent-core-v2/src/agent/skill/skillService.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext';
1515
import { Service } from '#/_base/di/service';
1616
import { ErrorCodes, Error2 } from '#/errors';
1717
import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types';
18-
import { IAgentPromptService, type PromptLaunchResult } from '#/agent/prompt/prompt';
18+
import { IAgentPromptService, reservePrompt, type PromptLaunchResult } from '#/agent/prompt/prompt';
1919
import { ITelemetryService } from '#/app/telemetry/telemetry';
2020
import { IAgentLoopService, type Turn } from '#/agent/loop/loop';
2121
import { IAgentStateService } from '#/agent/state/agentState';
@@ -24,6 +24,7 @@ import {
2424
IAgentSkillService,
2525
type PromptSkillActivation,
2626
type PromptWithSkillsInput,
27+
type PromptWithSkillsResult,
2728
type SkillActivationInput,
2829
} from './skill';
2930
import { SkillActivate, skillKey } from './skillOps';
@@ -114,7 +115,7 @@ export class AgentSkillService extends Service implements IAgentSkillService {
114115
return { turn_id: turn.id };
115116
}
116117

117-
async promptWithSkills(input: PromptWithSkillsInput): Promise<PromptLaunchResult | undefined> {
118+
async promptWithSkills(input: PromptWithSkillsInput): Promise<PromptWithSkillsResult> {
118119
if (input.input.length === 0) {
119120
throw new Error2(ErrorCodes.REQUEST_INVALID, 'promptWithSkills requires a non-empty prompt');
120121
}
@@ -139,20 +140,33 @@ export class AgentSkillService extends Service implements IAgentSkillService {
139140
for (const activation of prepared) {
140141
void this.recordActivation(activation.origin);
141142
}
142-
const handle = await this.prompt.enqueue({
143-
message: {
143+
const reservation = reservePrompt(this.prompt);
144+
try {
145+
const handle = await reservation.submit({
144146
role: 'user',
145147
content: [...prepared.map((activation) => activation.part), ...input.input],
146148
toolCalls: [],
147149
origin: {
148150
kind: 'user',
149151
skillActivations: prepared.map((activation) => activation.entry),
150152
},
151-
},
152-
});
153-
if (handle.state === 'pending') return undefined;
154-
const turn = await handle.launched;
155-
return turn === undefined ? undefined : { turn_id: turn.id };
153+
});
154+
if (handle.state === 'pending') {
155+
return { prompt_id: handle.id, created_at: handle.createdAt, state: 'queued' };
156+
}
157+
const turn = await handle.launched;
158+
if (turn === undefined && handle.state !== 'blocked') {
159+
throw new Error2(ErrorCodes.INTERNAL, 'promptWithSkills failed to launch a turn');
160+
}
161+
return {
162+
turn_id: turn?.id,
163+
prompt_id: handle.id,
164+
created_at: handle.createdAt,
165+
state: handle.state === 'blocked' ? 'blocked' : 'running',
166+
};
167+
} finally {
168+
reservation.dispose();
169+
}
156170
}
157171

158172
recordModelToolActivation(origin: SkillActivationOrigin): void {

packages/agent-core-v2/test/agent/loop/stubs.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { toDisposable } from '#/_base/di/lifecycle';
22
import { Event } from '#/_base/event';
3-
import type { IAgentLoopService, LoopErrorHandler, LoopErrorHandlerRegistrationOptions, Step, Turn } from '#/agent/loop/loop';
3+
import type { IAgentLoopService, LoopErrorHandler, LoopErrorHandlerRegistrationOptions, Step, Turn, TurnResult } from '#/agent/loop/loop';
44
import type { StepRequest } from '#/agent/loop/stepRequest';
55
import { StepRequestQueue, type StepRequestBatch } from '#/agent/loop/stepRequestQueue';
66
import type { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
@@ -10,12 +10,13 @@ import type { ContextMessage } from '#/agent/contextMemory/types';
1010
import { createHooks } from '#/hooks';
1111
import type { IWireService } from '#/wire/wire';
1212

13-
export interface StubLoopOptions { readonly hasActiveTurn?: boolean; readonly currentId?: string | number; readonly pendingTurnResult?: boolean }
13+
export interface StubLoopOptions { readonly hasActiveTurn?: boolean; readonly currentId?: string | number; readonly pendingTurnResult?: boolean; readonly manualTurnResult?: boolean }
1414
export type StubLoop = IAgentLoopService & {
1515
readonly queue: StepRequestQueue;
1616
readonly launches: readonly number[];
1717
readonly cancels: readonly { readonly turnId?: number; readonly reason?: unknown }[];
1818
startTurn(): Turn;
19+
settleActive(result?: TurnResult): void;
1920
drainNextBatch(context: { append(...messages: ContextMessage[]): void }): StepRequestBatch | undefined;
2021
};
2122
const turnControllers = new WeakMap<Turn, AbortController>();
@@ -44,14 +45,18 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop {
4445
const hooks = createHooks(['onWillBeginStep', 'onDidFinishStep']) as IAgentLoopService['hooks'];
4546
const queue = new StepRequestQueue(); const errorHandlers = registry(); const launches: number[] = []; const cancels: { turnId?: number; reason?: unknown }[] = [];
4647
let active: Turn | undefined; let nextId = typeof options.currentId === 'number' ? options.currentId : 0;
48+
let releaseActiveResult: ((result: TurnResult) => void) | undefined;
4749
const startTurn = () => {
4850
const turn = makeTurn(nextId++);
49-
const result = options.pendingTurnResult === true ? new Promise<never>(() => {}) : turn.result;
51+
const result = options.manualTurnResult === true
52+
? new Promise<TurnResult>((resolve) => { releaseActiveResult = resolve; })
53+
: options.pendingTurnResult === true ? new Promise<never>(() => {}) : turn.result;
5054
const configured = { ...turn, result };
5155
launches.push(configured.id); active = configured; return configured;
5256
};
5357
const stub: StubLoop = {
5458
_serviceBrand: undefined, hooks, queue, launches, cancels, startTurn,
59+
settleActive(result = { type: 'completed', steps: 0, truncated: false }) { releaseActiveResult?.(result); },
5560
enqueue(request, enqueueOptions) {
5661
let turn = active;
5762
if (request.admission === 'newTurn' || (request.admission === 'activeOrNewTurn' && turn === undefined)) turn = startTurn();

0 commit comments

Comments
 (0)