Skip to content

Commit c8fe6fd

Browse files
committed
feat(workflow): per-task subagent routing and a spawn-plan telemetry event
AgentDynamicWorkflow could only spawn homogeneous subagents: one subagent_type and one model for every item. - Input gains tasks[] ({ item, subagent_type?, model?, thinking? }) and defaults.subagent_type beside the homogeneous items shape; tasks and items cannot be combined. Each task resolves through the routing service, so a workflow can mix profiles, models, and thinking efforts and every plan carries its own provenance. Profile choices stay the catalog's; the tool never hard-codes them. - SubagentSpawnPlanInput accepts an explicit thinking effort; it is part of the route decision fingerprint, never of the environment revision. - The routing service emits subagent_spawn_plan_resolved (operation, profile/model source, policy mode and source, feature source, the two routing hashes, and which inputs were explicit) with no prompt content. - Token-count expectations and wire snapshots follow the larger tool schema.
1 parent 0ff3401 commit c8fe6fd

12 files changed

Lines changed: 255 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
AgentDynamicWorkflow accepts a `tasks` list where each entry sets its own subagent type, model, and thinking effort. Pass `tasks` instead of `items`, with optional `defaults.subagent_type`.

packages/agent-core-v2/src/app/telemetry/events.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,20 @@ export interface SubagentCreatedEvent {
389389
model?: string;
390390
}
391391

392+
export interface SubagentSpawnPlanResolvedEvent {
393+
operation: 'spawn' | 'fork';
394+
profile_source: 'requested' | 'default' | 'fork-inherit' | 'resume-existing';
395+
model_source: 'caller' | 'policy-default' | 'policy-pool' | 'policy-force' | 'fork-inherit' | 'resume-existing';
396+
policy_mode: 'inherit' | 'default' | 'pool' | 'force';
397+
policy_source: 'config' | 'default';
398+
feature_source: 'master-env' | 'env' | 'config' | 'default';
399+
routing_env_revision: string;
400+
route_decision: string;
401+
explicit_profile: boolean;
402+
explicit_model: boolean;
403+
explicit_thinking: boolean;
404+
}
405+
392406
export interface McpConnectedEvent {
393407
server_count: number;
394408
total_count: number;
@@ -961,6 +975,23 @@ export const telemetryEventDefinitions = {
961975
model: 'Model alias the subagent binds to (secondary-model choice or inherited caller model); omitted when no binding was resolved',
962976
},
963977
}),
978+
subagent_spawn_plan_resolved: defineTelemetryEvent<SubagentSpawnPlanResolvedEvent>({
979+
owner: 'pythinker-code',
980+
comment: 'The routing resolver produced a spawn plan for a new subagent (no prompt content).',
981+
properties: {
982+
operation: 'spawn or fork',
983+
profile_source: 'Where the profile came from: requested, default, or fork-inherit',
984+
model_source: 'Where the model came from: caller, policy-default, policy-pool, policy-force, or fork-inherit',
985+
policy_mode: 'Effective subagent model policy mode',
986+
policy_source: 'Whether the policy came from config or the default',
987+
feature_source: 'Where the secondary-model feature state came from',
988+
routing_env_revision: 'Hash of the ambient routing inputs the plan was resolved from',
989+
route_decision: 'Hash of the routing environment plus the request intent',
990+
explicit_profile: 'Whether the request named a subagent type',
991+
explicit_model: 'Whether the request named a model',
992+
explicit_thinking: 'Whether the request named a thinking effort',
993+
},
994+
}),
964995
mcp_connected: defineTelemetryEvent<McpConnectedEvent>({
965996
owner: 'pythinker-code',
966997
comment: 'MCP servers connect at session start.',

packages/agent-core-v2/src/features/dynamic_workflow/tools/agent-dynamic_workflow/agent-dynamic_workflow.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,41 @@ export const AgentDynamicWorkflowToolInputSchema = z
5050
.describe(
5151
'Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.',
5252
),
53+
defaults: z
54+
.object({
55+
subagent_type: z.string().trim().min(1).optional(),
56+
})
57+
.strict()
58+
.optional()
59+
.describe('Defaults applied to every entry of tasks that does not set its own value.'),
60+
tasks: z
61+
.array(
62+
z
63+
.object({
64+
item: z.string().trim().min(1).describe(`Value used to fill ${PROMPT_TEMPLATE_PLACEHOLDER} for this subagent.`),
65+
subagent_type: z.string().trim().min(1).optional().describe('Subagent type for this subagent; overrides defaults.subagent_type and subagent_type.'),
66+
model: z.string().optional().describe('Model alias for this subagent (same vocabulary as model); overrides model.'),
67+
thinking: z.string().optional().describe('Thinking effort for this subagent; overrides the model default.'),
68+
})
69+
.strict(),
70+
)
71+
.max(MAX_AGENT_DYNAMIC_WORKFLOW_SUBAGENTS)
72+
.optional()
73+
.describe(
74+
'Per-subagent entries with their own subagent_type, model, and thinking. Use instead of items when the subagents differ; tasks and items cannot be combined.',
75+
),
5376
model: z
5477
.string()
5578
.optional()
5679
.describe(
5780
'Which model to run the item-spawned subagents on: one of the aliases listed under "Available models" in this tool description, or "primary" for the main model you are running on (for hard, quality-sensitive tasks). When omitted, the configured default model is used. Resumed subagents always keep their own model.',
5881
),
5982
})
60-
.strict();
83+
.strict()
84+
.refine((input) => !(input.tasks !== undefined && input.items !== undefined), {
85+
message: 'tasks and items cannot be combined; use one of them.',
86+
path: ['tasks'],
87+
});
6188

6289
export type AgentDynamicWorkflowToolInput = z.infer<typeof AgentDynamicWorkflowToolInputSchema>;
6390

packages/agent-core-v2/src/features/dynamic_workflow/tools/agent-dynamic_workflow/agentDynamicWorkflowTool.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ interface AgentDynamicWorkflowSpawnSpec {
4848
readonly index: number;
4949
readonly item: string;
5050
readonly prompt: string;
51+
readonly task?: {
52+
readonly subagent_type?: string;
53+
readonly model?: string;
54+
readonly thinking?: string;
55+
};
5156
}
5257

5358
interface AgentDynamicWorkflowResumeSpec {
@@ -173,12 +178,41 @@ export class AgentDynamicWorkflowTool implements IAgentDynamicWorkflowTool {
173178
fork,
174179
});
175180
}
176-
const profileName = plan?.profileName ?? DEFAULT_SUBAGENT_TYPE;
177181
const timeoutMs = resolveDynamicWorkflowTimeoutMs(this.config);
178182
const specs = await createAgentDynamicWorkflowSpecs(args, (agentId) =>
179183
this.dynamicWorkflowService.getDynamicWorkflowItem({ callerAgentId: this.callerAgentId, agentId }),
180184
);
185+
const plansByIndex = new Map<number, SubagentSpawnPlan>();
186+
for (const spec of specs) {
187+
if (spec.kind !== 'spawn') continue;
188+
if (spec.task === undefined) {
189+
plansByIndex.set(spec.index, plan!);
190+
continue;
191+
}
192+
const profileName = spec.task.subagent_type ?? args.defaults?.subagent_type ?? args.subagent_type;
193+
if (fork) {
194+
const incompatible = forkIncompatibility(
195+
{ subagent_type: profileName, model: spec.task.model ?? args.model },
196+
this.profile.data(),
197+
);
198+
if (incompatible !== undefined) {
199+
throw new Error2(ErrorCodes.VALIDATION_FAILED, incompatible);
200+
}
201+
}
202+
plansByIndex.set(
203+
spec.index,
204+
await this.subagents.planSpawn({
205+
callerAgentId: this.callerAgentId,
206+
profileName,
207+
model: spec.task.model ?? args.model,
208+
thinking: spec.task.thinking,
209+
fork,
210+
}),
211+
);
212+
}
181213
const tasks: SessionDynamicWorkflowTask<AgentDynamicWorkflowSpec>[] = specs.map((spec) => {
214+
const specPlan = spec.kind === 'spawn' ? plansByIndex.get(spec.index) : undefined;
215+
const profileName = specPlan?.profileName ?? DEFAULT_SUBAGENT_TYPE;
182216
const descriptionName = spec.kind === 'resume' ? 'resume' : profileName;
183217
const common = {
184218
data: spec,
@@ -202,7 +236,7 @@ export class AgentDynamicWorkflowTool implements IAgentDynamicWorkflowTool {
202236
return {
203237
...common,
204238
kind: 'spawn' as const,
205-
plan: plan!,
239+
plan: specPlan!,
206240
};
207241
});
208242
const results = await this.dynamicWorkflowService.run({
@@ -223,7 +257,8 @@ async function createAgentDynamicWorkflowSpecs(
223257
agentId: agentId.trim(),
224258
prompt: prompt.trim(),
225259
}));
226-
const items = (args.items ?? []).map((item) => item.trim());
260+
const taskEntries = (args.tasks ?? []).map((task) => ({ ...task, item: task.item.trim() }));
261+
const items = taskEntries.length > 0 ? taskEntries.map((task) => task.item) : (args.items ?? []).map((item) => item.trim());
227262
const itemCount = items.length;
228263
const resumeCount = resumeEntries.length;
229264
const totalCount = resumeCount + itemCount;
@@ -279,11 +314,13 @@ async function createAgentDynamicWorkflowSpecs(
279314
);
280315
}
281316
seenPrompts.set(prompt, index + 1);
317+
const task = taskEntries[index];
282318
specs.push({
283319
kind: 'spawn',
284320
index: specs.length + 1,
285321
item,
286322
prompt,
323+
task: task === undefined ? undefined : { subagent_type: task.subagent_type, model: task.model, thinking: task.thinking },
287324
});
288325
});
289326
}

packages/agent-core-v2/src/session/subagent/spawn.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export interface SubagentSpawnPlanInput {
4949
readonly callerAgentId: string;
5050
readonly profileName?: string;
5151
readonly model?: string;
52+
readonly thinking?: string;
5253
readonly fork?: boolean;
5354
}
5455

packages/agent-core-v2/src/session/subagent/subagentRoutingService.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { IAgentProfileService } from '#/agent/profile/profile';
1414
import { IConfigService } from '#/app/config/config';
1515
import { IModelCatalog, type Model } from '#/kosong/model/catalog';
1616
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
17+
import type { SubagentSpawnPlanResolvedEvent } from '#/app/telemetry/events';
18+
import { ITelemetryService } from '#/app/telemetry/telemetry';
1719

1820
import { IAgentBindingProvenanceService } from './bindingProvenance';
1921
import { resolveSubagentThinking, wrapSubagentModelError } from './configSection';
@@ -57,6 +59,7 @@ export class SessionSubagentRoutingService implements ISubagentRoutingService {
5759
@IConfigService private readonly configService: IConfigService,
5860
@IModelCatalog private readonly modelCatalog: IModelCatalog,
5961
@ISubagentModelPolicyService private readonly policy: ISubagentModelPolicyService,
62+
@ITelemetryService private readonly telemetry: ITelemetryService,
6063
) {}
6164

6265
async resolve(input: SubagentSpawnPlanInput): Promise<ResolvedSpawnPlan> {
@@ -115,27 +118,44 @@ export class SessionSubagentRoutingService implements ISubagentRoutingService {
115118
throw wrapSubagentModelError(error, route.model, own.modelAlias);
116119
}
117120
const operation = fork ? 'fork' : 'spawn';
118-
return {
121+
const routing: SubagentBindingProvenance = {
122+
operation,
123+
profileSource,
124+
modelSource: route.source,
125+
policyMode: effective.effectivePolicy.mode,
126+
policySource: effective.policySource,
127+
featureSource: effective.feature.source,
128+
resolvedFromRoutingEnvironmentRevision: environmentRevision,
129+
routeDecisionFingerprint: routeDecisionFingerprint({
130+
routingEnvironmentRevision: environmentRevision,
131+
operation,
132+
profile: requested,
133+
model: input.model,
134+
thinking: input.thinking,
135+
}),
136+
};
137+
const plan: ResolvedSpawnPlan = {
119138
profileName: profile?.name ?? requestedProfileName,
120139
model: route.model,
121-
thinking: resolveSubagentThinking(this.configService, model, route.thinking),
140+
thinking: resolveSubagentThinking(this.configService, model, input.thinking ?? route.thinking),
122141
fork,
123-
routing: {
124-
operation,
125-
profileSource,
126-
modelSource: route.source,
127-
policyMode: effective.effectivePolicy.mode,
128-
policySource: effective.policySource,
129-
featureSource: effective.feature.source,
130-
resolvedFromRoutingEnvironmentRevision: environmentRevision,
131-
routeDecisionFingerprint: routeDecisionFingerprint({
132-
routingEnvironmentRevision: environmentRevision,
133-
operation,
134-
profile: requested,
135-
model: input.model,
136-
}),
137-
},
142+
routing,
143+
};
144+
const telemetryEvent: SubagentSpawnPlanResolvedEvent = {
145+
operation,
146+
profile_source: profileSource,
147+
model_source: route.source,
148+
policy_mode: routing.policyMode,
149+
policy_source: routing.policySource,
150+
feature_source: routing.featureSource,
151+
routing_env_revision: environmentRevision,
152+
route_decision: routing.routeDecisionFingerprint,
153+
explicit_profile: requested !== undefined,
154+
explicit_model: input.model !== undefined,
155+
explicit_thinking: input.thinking !== undefined,
138156
};
157+
this.telemetry.track2('subagent_spawn_plan_resolved', telemetryEvent);
158+
return plan;
139159
}
140160

141161
resumed(callerAgentId: string, child: IAgentScopeHandle): ResumedSubagentRouting {

packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ describe('FullCompaction', () => {
298298
properties: expect.objectContaining({
299299
agent_id: 'main',
300300
source: 'manual',
301-
tokens_before: 6_257,
301+
tokens_before: 6_504,
302302
tokens_after: expect.any(Number),
303303
duration_ms: expect.any(Number),
304304
compacted_count: 6,
@@ -572,7 +572,7 @@ describe('FullCompaction', () => {
572572
session_id: 'test-session',
573573
cwd: dir,
574574
trigger: 'auto',
575-
token_count: 6_257,
575+
token_count: 6_504,
576576
});
577577
expect(post).toMatchObject({
578578
hook_event_name: 'PostCompact',
@@ -658,7 +658,7 @@ describe('FullCompaction', () => {
658658
event: 'compaction_finished',
659659
properties: expect.objectContaining({
660660
source: 'manual',
661-
tokens_before: 17_943,
661+
tokens_before: 18_190,
662662
retry_count: 1,
663663
trace_id: 'trace-compact-1',
664664
}),
@@ -1125,7 +1125,7 @@ describe('FullCompaction', () => {
11251125
properties: expect.objectContaining({
11261126
agent_id: 'main',
11271127
source: 'manual',
1128-
tokens_before: 17_943,
1128+
tokens_before: 18_190,
11291129
duration_ms: expect.any(Number),
11301130
round: 1,
11311131
retry_count: 0,
@@ -1350,7 +1350,7 @@ describe('FullCompaction', () => {
13501350
event: 'compaction_failed',
13511351
properties: expect.objectContaining({
13521352
source: 'manual',
1353-
tokens_before: 17_943,
1353+
tokens_before: 18_190,
13541354
duration_ms: expect.any(Number),
13551355
retry_count: 4,
13561356
error_type: 'APIConnectionError',
@@ -1723,8 +1723,8 @@ describe('FullCompaction', () => {
17231723
event: 'compaction_finished',
17241724
properties: expect.objectContaining({
17251725
source: 'auto',
1726-
tokens_before: 6_264,
1727-
tokens_after: 6_248,
1726+
tokens_before: 6_511,
1727+
tokens_after: 6_495,
17281728
compacted_count: 7,
17291729
retry_count: 0,
17301730
}),

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

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ITelemetryService } from '#/app/telemetry/telemetry';
12
import { SubagentModelPolicyService } from '#/session/subagent/subagentModelPolicyService';
23
import { SessionSubagentRoutingService } from '#/session/subagent/subagentRoutingService';
34
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
@@ -275,6 +276,7 @@ function realSubagents(
275276
config,
276277
modelCatalog,
277278
new SubagentModelPolicyService(config, flags, modelCatalog),
279+
{ _serviceBrand: undefined, track2: vi.fn(), track: vi.fn() } as unknown as ITelemetryService,
278280
);
279281
return new SessionSubagentService(lifecycle, catalog, sessionContext, stubLog(), routing);
280282
}
@@ -665,6 +667,50 @@ describe('dynamic_workflow context reconciliation', () => {
665667
});
666668

667669
describe('AgentDynamicWorkflowTool', () => {
670+
it('resolves a plan per task so a workflow can mix subagent types, models, and thinking', async () => {
671+
const host = mockDynamicWorkflowHost({
672+
run: vi.fn().mockImplementation(async ({ tasks }) =>
673+
tasks.map((task: { kind: string; data: { index: number; item?: string }; plan?: unknown }) => ({
674+
task,
675+
agentId: `agent-${task.data.index}`,
676+
status: 'completed',
677+
result: `done ${task.data.item ?? ''}`,
678+
})),
679+
),
680+
});
681+
const dynamicWorkflowMode = mockDynamicWorkflowMode();
682+
const cfg = stubConfig({
683+
defaultModel: 'provider/fast',
684+
models: { 'provider/fast': 'fast and cheap', 'provider/smart': 'hard tasks' },
685+
});
686+
const tool = new AgentDynamicWorkflowTool(host.dynamicWorkflowService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), dynamicWorkflowMode, cfg, stubFlag(true), realSubagents(stubDynamicWorkflowCatalog(), cfg, stubFlag(true), stubCallerProfile()), stubCallerProfile());
687+
const input = {
688+
description: 'Review files',
689+
prompt_template: 'Review {{item}}',
690+
defaults: { subagent_type: 'explore' },
691+
tasks: [
692+
{ item: 'src/a.ts' },
693+
{ item: 'src/b.ts', subagent_type: 'coder', model: 'provider/smart', thinking: 'low' },
694+
{ item: 'src/c.ts', model: 'primary' },
695+
],
696+
};
697+
expect(AgentDynamicWorkflowToolInputSchema.safeParse(input).success).toBe(true);
698+
expect(AgentDynamicWorkflowToolInputSchema.safeParse({ ...input, items: ['src/d.ts'] }).success).toBe(false);
699+
700+
const result = await executeTool(tool, context(input));
701+
expect(result.isError).toBeUndefined();
702+
const call = (host.dynamicWorkflowService.run as ReturnType<typeof vi.fn>).mock.calls[0]![0] as {
703+
tasks: Array<{ kind: string; profileName: string; plan: { profileName: string; model: string; thinking?: string; routing?: { modelSource: string } } }>;
704+
};
705+
expect(call.tasks.map((task) => [task.profileName, task.plan.model, task.plan.thinking, task.plan.routing?.modelSource])).toEqual([
706+
['explore', 'provider/fast', undefined, 'policy-pool'],
707+
['coder', 'provider/smart', 'low', 'policy-pool'],
708+
['explore', 'mock-model', 'off', 'caller'],
709+
]);
710+
expect(result.output).toContain('<subagent agent_id="agent-1" item="src/a.ts"');
711+
expect(result.output).toContain('<subagent agent_id="agent-2" item="src/b.ts"');
712+
});
713+
668714
it('renders durable binding attributes on each subagent row and escapes them', async () => {
669715
const host = mockDynamicWorkflowHost({
670716
run: vi.fn().mockResolvedValue([

0 commit comments

Comments
 (0)