feat(subagents): complete queue, budgets, audit, and inspection controls
This commit is contained in:
@@ -123,6 +123,26 @@ describe('AuditLogger', () => {
|
||||
reason: 'no_match',
|
||||
candidate_count: 4,
|
||||
});
|
||||
logger.subagentLifecycle({
|
||||
parent_session_id: 'telegram:123',
|
||||
subagent_id: 'planner',
|
||||
trace_id: 'trace-planner',
|
||||
action: 'spawn',
|
||||
agent: 'research',
|
||||
tier: 'complex',
|
||||
queue_mode: 'followup',
|
||||
tool_profile: 'minimal',
|
||||
});
|
||||
logger.subagentTurn({
|
||||
parent_session_id: 'telegram:123',
|
||||
subagent_id: 'planner',
|
||||
trace_id: 'trace-planner',
|
||||
action: 'complete',
|
||||
queue_mode: 'followup',
|
||||
duration_ms: 88,
|
||||
input_chars: 42,
|
||||
output_chars: 120,
|
||||
});
|
||||
|
||||
await logger.close();
|
||||
await waitForFlush();
|
||||
@@ -135,6 +155,8 @@ describe('AuditLogger', () => {
|
||||
expect(eventTypes).toContain('run.cancel');
|
||||
expect(eventTypes).toContain('reaction.match');
|
||||
expect(eventTypes).toContain('reaction.skip');
|
||||
expect(eventTypes).toContain('subagent.lifecycle');
|
||||
expect(eventTypes).toContain('subagent.turn');
|
||||
|
||||
const runError = events.find((event) => (
|
||||
event.event_type === 'run.state'
|
||||
@@ -145,6 +167,10 @@ describe('AuditLogger', () => {
|
||||
const reactionSkip = events.find((event) => event.event_type === 'reaction.skip');
|
||||
expect(reactionSkip?.level).toBe('debug');
|
||||
expect(reactionSkip?.event.reason).toBe('no_match');
|
||||
|
||||
const subagentLifecycle = events.find((event) => event.event_type === 'subagent.lifecycle');
|
||||
expect(subagentLifecycle?.level).toBe('info');
|
||||
expect(subagentLifecycle?.event.action).toBe('spawn');
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
|
||||
@@ -26,6 +26,8 @@ import type {
|
||||
RunCancelEvent,
|
||||
ReactionMatchEvent,
|
||||
ReactionSkipEvent,
|
||||
SubagentLifecycleEvent,
|
||||
SubagentTurnEvent,
|
||||
BackendRouteEvent,
|
||||
BackendSuccessEvent,
|
||||
BackendFallbackEvent,
|
||||
@@ -237,6 +239,17 @@ export class AuditLogger {
|
||||
this.write({ level: 'debug', event_type: 'reaction.skip', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
subagentLifecycle(event: SubagentLifecycleEvent): void {
|
||||
if (!this.shouldLog('sessions', 'info')) {return;}
|
||||
this.write({ level: 'info', event_type: 'subagent.lifecycle', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
subagentTurn(event: SubagentTurnEvent): void {
|
||||
const level = event.action === 'error' ? 'warn' : 'debug';
|
||||
if (!this.shouldLog('sessions', level)) {return;}
|
||||
this.write({ level, event_type: 'subagent.turn', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
backendRoute(event: BackendRouteEvent): void {
|
||||
if (!this.shouldLog('sessions', 'info')) {return;}
|
||||
this.write({ level: 'info', event_type: 'backend.route', event: event as unknown as Record<string, unknown> });
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AuditEventType =
|
||||
| 'queue.preempt'
|
||||
| 'run.state' | 'run.cancel'
|
||||
| 'reaction.match' | 'reaction.skip'
|
||||
| 'subagent.lifecycle' | 'subagent.turn'
|
||||
| 'backend.route' | 'backend.success' | 'backend.fallback'
|
||||
// Automation - Cron
|
||||
| 'cron.trigger' | 'cron.sent' | 'cron.add' | 'cron.remove'
|
||||
@@ -303,6 +304,34 @@ export interface BackendFallbackEvent {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
export interface SubagentLifecycleEvent {
|
||||
parent_session_id: string;
|
||||
subagent_id: string;
|
||||
trace_id: string;
|
||||
action: 'spawn' | 'cancel' | 'delete' | 'ttl_evict' | 'summary';
|
||||
agent?: string;
|
||||
tier?: string;
|
||||
queue_mode?: 'followup' | 'interrupt';
|
||||
tool_profile?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SubagentTurnEvent {
|
||||
parent_session_id: string;
|
||||
subagent_id: string;
|
||||
trace_id: string;
|
||||
action: 'queued' | 'superseded' | 'start' | 'complete' | 'error';
|
||||
request_id?: string;
|
||||
queue_mode?: 'followup' | 'interrupt';
|
||||
pending_count?: number;
|
||||
duration_ms?: number;
|
||||
error?: string;
|
||||
input_chars?: number;
|
||||
output_chars?: number;
|
||||
turn_count?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
export interface CronTriggerEvent {
|
||||
job_name: string;
|
||||
schedule: string;
|
||||
|
||||
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => {
|
||||
processCalls,
|
||||
cancellable: true,
|
||||
cancelCalls: 0,
|
||||
usageInput: 0,
|
||||
usageOutput: 0,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -29,6 +31,8 @@ vi.mock('./orchestrator.js', () => {
|
||||
async process(message: string): Promise<string> {
|
||||
mocks.processCalls.push(message);
|
||||
const output = `subagent:${message}`;
|
||||
mocks.usageInput += Math.ceil(message.length / 4);
|
||||
mocks.usageOutput += Math.ceil(output.length / 4);
|
||||
this.session.addMessage({ role: 'user', content: message });
|
||||
this.session.addMessage({ role: 'assistant', content: output });
|
||||
return output;
|
||||
@@ -41,6 +45,23 @@ vi.mock('./orchestrator.js', () => {
|
||||
cancel(): void {
|
||||
mocks.cancelCalls += 1;
|
||||
}
|
||||
|
||||
getUsage(): {
|
||||
primary: { inputTokens: number; outputTokens: number; calls: number };
|
||||
delegation: Record<string, { inputTokens: number; outputTokens: number; calls: number }>;
|
||||
total: { inputTokens: number; outputTokens: number; calls: number; estimatedCost: number };
|
||||
} {
|
||||
return {
|
||||
primary: { inputTokens: mocks.usageInput, outputTokens: mocks.usageOutput, calls: mocks.processCalls.length },
|
||||
delegation: {},
|
||||
total: {
|
||||
inputTokens: mocks.usageInput,
|
||||
outputTokens: mocks.usageOutput,
|
||||
calls: mocks.processCalls.length,
|
||||
estimatedCost: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { AgentOrchestrator };
|
||||
@@ -122,6 +143,8 @@ describe('SubagentManager', () => {
|
||||
mocks.processCalls.length = 0;
|
||||
mocks.cancellable = true;
|
||||
mocks.cancelCalls = 0;
|
||||
mocks.usageInput = 0;
|
||||
mocks.usageOutput = 0;
|
||||
});
|
||||
|
||||
it('spawns, sends, lists, cancels, and deletes subagent sessions', async () => {
|
||||
@@ -162,8 +185,13 @@ describe('SubagentManager', () => {
|
||||
},
|
||||
maxDelegationDepth: 3,
|
||||
defaultPrimaryTier: 'default',
|
||||
defaultQueueMode: 'followup',
|
||||
defaultToolProfile: 'minimal',
|
||||
maxIterations: 12,
|
||||
maxActiveSessions: 2,
|
||||
maxTurns: 40,
|
||||
maxTotalTokens: 200000,
|
||||
turnTimeoutMs: 120000,
|
||||
idleTtlMs: 60000,
|
||||
});
|
||||
|
||||
@@ -171,6 +199,7 @@ describe('SubagentManager', () => {
|
||||
expect(spawned.id).toBe('planner');
|
||||
expect(spawned.agent).toBe('research');
|
||||
expect(spawned.tier).toBe('complex');
|
||||
expect(spawned.queueMode).toBe('followup');
|
||||
|
||||
// verify blocked orchestration tools are not passed to child subagents
|
||||
const ctorConfig = mocks.ctorConfigs[0] as { toolRegistry: ToolRegistry };
|
||||
@@ -220,7 +249,12 @@ describe('SubagentManager', () => {
|
||||
},
|
||||
maxDelegationDepth: 3,
|
||||
defaultPrimaryTier: 'default',
|
||||
defaultQueueMode: 'followup',
|
||||
defaultToolProfile: 'minimal',
|
||||
maxActiveSessions: 1,
|
||||
maxTurns: 40,
|
||||
maxTotalTokens: 200000,
|
||||
turnTimeoutMs: 120000,
|
||||
idleTtlMs: 60000,
|
||||
});
|
||||
|
||||
@@ -249,7 +283,12 @@ describe('SubagentManager', () => {
|
||||
},
|
||||
maxDelegationDepth: 3,
|
||||
defaultPrimaryTier: 'default',
|
||||
defaultQueueMode: 'followup',
|
||||
defaultToolProfile: 'minimal',
|
||||
maxActiveSessions: 3,
|
||||
maxTurns: 40,
|
||||
maxTotalTokens: 200000,
|
||||
turnTimeoutMs: 120000,
|
||||
idleTtlMs: 60000,
|
||||
});
|
||||
|
||||
@@ -274,7 +313,12 @@ describe('SubagentManager', () => {
|
||||
},
|
||||
maxDelegationDepth: 3,
|
||||
defaultPrimaryTier: 'default',
|
||||
defaultQueueMode: 'followup',
|
||||
defaultToolProfile: 'minimal',
|
||||
maxActiveSessions: 3,
|
||||
maxTurns: 40,
|
||||
maxTotalTokens: 200000,
|
||||
turnTimeoutMs: 120000,
|
||||
idleTtlMs: 1000,
|
||||
});
|
||||
|
||||
@@ -284,4 +328,47 @@ describe('SubagentManager', () => {
|
||||
expect(removed).toEqual(['ttl-one']);
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('enforces per-session turn/token budgets and interrupt latest-wins behavior', async () => {
|
||||
const sessionManager = createSessionManagerMock();
|
||||
const manager = new SubagentManager({
|
||||
parentSessionId: 'telegram:eve',
|
||||
modelRouter: {} as never,
|
||||
sessionManager: sessionManager.api as never,
|
||||
toolRegistry: new ToolRegistry(),
|
||||
toolExecutor: {} as never,
|
||||
agentConfigRegistry: createAgentRegistryMock() as never,
|
||||
delegation: {
|
||||
compaction: 'fast',
|
||||
memory_extraction: 'fast',
|
||||
classification: 'fast',
|
||||
tool_summarisation: 'fast',
|
||||
complex_reasoning: 'complex',
|
||||
},
|
||||
maxDelegationDepth: 3,
|
||||
defaultPrimaryTier: 'default',
|
||||
defaultQueueMode: 'interrupt',
|
||||
defaultToolProfile: 'minimal',
|
||||
maxActiveSessions: 3,
|
||||
maxTurns: 2,
|
||||
maxTotalTokens: 200000,
|
||||
turnTimeoutMs: 120000,
|
||||
idleTtlMs: 60000,
|
||||
});
|
||||
|
||||
manager.spawn({ agent: 'helper', subagentId: 'interrupt-one' });
|
||||
const p1 = manager.send('interrupt-one', 'first request');
|
||||
const p2 = manager.send('interrupt-one', 'second request');
|
||||
|
||||
const [r1, r2] = await Promise.allSettled([p1, p2]);
|
||||
expect(mocks.cancelCalls).toBeGreaterThanOrEqual(1);
|
||||
if (r1.status === 'fulfilled') {
|
||||
expect(r1.value.content).toContain('subagent:');
|
||||
}
|
||||
expect(r2.status).toBe('fulfilled');
|
||||
if (r2.status === 'fulfilled') {
|
||||
expect(r2.value.content).toBe('subagent:second request');
|
||||
}
|
||||
await expect(manager.send('interrupt-one', 'third request')).rejects.toThrow('max turns');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AgentConfigRegistry } from '../../agents/registry.js';
|
||||
import type { ToolProfile } from '../../config/schema.js';
|
||||
import type { Message } from '../../models/types.js';
|
||||
import type { ToolPolicyContext } from '../../tools/policy.js';
|
||||
import { PROFILE_TOOLS } from '../../tools/policy.js';
|
||||
import type { ModelRouter, ModelTier } from '../../models/router.js';
|
||||
import type { SessionManager } from '../../session/manager.js';
|
||||
import type { ToolRegistry } from '../../tools/registry.js';
|
||||
import type { ToolExecutor } from '../../tools/executor.js';
|
||||
import { auditLogger } from '../../audit/index.js';
|
||||
import { AgentOrchestrator, type DelegationConfig } from './orchestrator.js';
|
||||
|
||||
const SUBAGENT_FRONTEND = 'subagent';
|
||||
|
||||
type SubagentQueueMode = 'followup' | 'interrupt';
|
||||
|
||||
const BLOCKED_SUBAGENT_TOOL_NAMES = [
|
||||
'agent.delegate',
|
||||
'council.run',
|
||||
@@ -21,6 +26,13 @@ const BLOCKED_SUBAGENT_TOOL_NAMES = [
|
||||
'subagent.summary',
|
||||
];
|
||||
|
||||
interface QueuedTurn {
|
||||
requestId: string;
|
||||
message: string;
|
||||
resolve: (result: SubagentSendResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface SubagentManagerConfig {
|
||||
parentSessionId: string;
|
||||
modelRouter: ModelRouter;
|
||||
@@ -31,8 +43,13 @@ export interface SubagentManagerConfig {
|
||||
delegation: DelegationConfig;
|
||||
maxDelegationDepth: number;
|
||||
defaultPrimaryTier: ModelTier;
|
||||
defaultQueueMode: SubagentQueueMode;
|
||||
defaultToolProfile: ToolProfile;
|
||||
maxIterations?: number;
|
||||
maxActiveSessions: number;
|
||||
maxTurns: number;
|
||||
maxTotalTokens: number;
|
||||
turnTimeoutMs: number;
|
||||
idleTtlMs: number;
|
||||
toolPolicyContext?: ToolPolicyContext;
|
||||
}
|
||||
@@ -42,16 +59,24 @@ export interface SpawnSubagentRequest {
|
||||
subagentId?: string;
|
||||
tier?: ModelTier;
|
||||
systemPrompt?: string;
|
||||
queueMode?: SubagentQueueMode;
|
||||
toolProfile?: ToolProfile;
|
||||
}
|
||||
|
||||
interface ManagedSubagent {
|
||||
id: string;
|
||||
agent: string;
|
||||
tier: ModelTier;
|
||||
queueMode: SubagentQueueMode;
|
||||
toolProfile: ToolProfile;
|
||||
traceId: string;
|
||||
sessionUserId: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
busy: boolean;
|
||||
processing: boolean;
|
||||
pending: QueuedTurn[];
|
||||
completedTurns: number;
|
||||
orchestrator: AgentOrchestrator;
|
||||
}
|
||||
|
||||
@@ -59,7 +84,12 @@ export interface SubagentSessionSummary {
|
||||
id: string;
|
||||
agent: string;
|
||||
tier: ModelTier;
|
||||
queueMode: SubagentQueueMode;
|
||||
toolProfile: ToolProfile;
|
||||
traceId: string;
|
||||
messageCount: number;
|
||||
completedTurns: number;
|
||||
pendingCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
busy: boolean;
|
||||
@@ -101,13 +131,13 @@ export class SubagentManager {
|
||||
if (!agentConfig) {
|
||||
const available = this.config.agentConfigRegistry.list().map((entry) => entry.name);
|
||||
throw new Error(
|
||||
`Agent \"${agentName}\" not found. Available agents: ${available.length > 0 ? available.join(', ') : 'none'}`,
|
||||
`Agent "${agentName}" not found. Available agents: ${available.length > 0 ? available.join(', ') : 'none'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = this.resolveSubagentId(request.subagentId);
|
||||
if (this.sessions.has(id)) {
|
||||
throw new Error(`Subagent session \"${id}\" already exists.`);
|
||||
throw new Error(`Subagent session "${id}" already exists.`);
|
||||
}
|
||||
if (this.sessions.size >= this.config.maxActiveSessions) {
|
||||
throw new Error(
|
||||
@@ -118,15 +148,16 @@ export class SubagentManager {
|
||||
const tier = request.tier ?? agentConfig.modelTier ?? this.config.defaultPrimaryTier;
|
||||
const systemPrompt = request.systemPrompt
|
||||
?? agentConfig.systemPrompt
|
||||
?? `You are subagent \"${agentName}\". Complete assigned tasks clearly and concisely.`;
|
||||
?? `You are subagent "${agentName}". Complete assigned tasks clearly and concisely.`;
|
||||
const queueMode = request.queueMode ?? this.config.defaultQueueMode;
|
||||
const toolProfile = request.toolProfile ?? agentConfig.toolProfile ?? this.config.defaultToolProfile;
|
||||
|
||||
const now = Date.now();
|
||||
const traceId = `subagent:${this.config.parentSessionId}:${id}:${randomUUID().slice(0, 8)}`;
|
||||
const sessionUserId = `${this.config.parentSessionId}:${id}`;
|
||||
const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, sessionUserId);
|
||||
|
||||
const subagentToolRegistry = this.config.toolRegistry.clone();
|
||||
for (const toolName of BLOCKED_SUBAGENT_TOOL_NAMES) {
|
||||
subagentToolRegistry.unregister(toolName);
|
||||
}
|
||||
const subagentToolRegistry = this.buildSubagentToolRegistry(toolProfile);
|
||||
|
||||
const policyContext: ToolPolicyContext | undefined = this.config.toolPolicyContext
|
||||
? {
|
||||
@@ -154,13 +185,30 @@ export class SubagentManager {
|
||||
id,
|
||||
agent: agentName,
|
||||
tier,
|
||||
queueMode,
|
||||
toolProfile,
|
||||
traceId,
|
||||
sessionUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
busy: false,
|
||||
processing: false,
|
||||
pending: [],
|
||||
completedTurns: 0,
|
||||
orchestrator: subagent,
|
||||
});
|
||||
|
||||
auditLogger?.subagentLifecycle?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: id,
|
||||
trace_id: traceId,
|
||||
action: 'spawn',
|
||||
agent: agentName,
|
||||
tier,
|
||||
queue_mode: queueMode,
|
||||
tool_profile: toolProfile,
|
||||
});
|
||||
|
||||
return this.getSummaryById(id);
|
||||
}
|
||||
|
||||
@@ -173,18 +221,53 @@ export class SubagentManager {
|
||||
throw new Error('message is required');
|
||||
}
|
||||
|
||||
subagent.busy = true;
|
||||
subagent.updatedAt = Date.now();
|
||||
try {
|
||||
const content = await subagent.orchestrator.process(trimmed);
|
||||
subagent.updatedAt = Date.now();
|
||||
return {
|
||||
content,
|
||||
session: this.getSummary(subagent),
|
||||
};
|
||||
} finally {
|
||||
subagent.busy = false;
|
||||
}
|
||||
this.assertBudgets(subagent);
|
||||
|
||||
return new Promise<SubagentSendResult>((resolve, reject) => {
|
||||
const requestId = `rq-${randomUUID().slice(0, 8)}`;
|
||||
const queued: QueuedTurn = { requestId, message: trimmed, resolve, reject };
|
||||
|
||||
if (subagent.queueMode === 'interrupt') {
|
||||
while (subagent.pending.length > 0) {
|
||||
const dropped = subagent.pending.shift();
|
||||
if (!dropped) {
|
||||
continue;
|
||||
}
|
||||
auditLogger?.subagentTurn?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'superseded',
|
||||
request_id: dropped.requestId,
|
||||
queue_mode: subagent.queueMode,
|
||||
});
|
||||
dropped.reject(new Error(`Superseded by newer subagent request (${requestId}).`));
|
||||
}
|
||||
if (subagent.busy && subagent.orchestrator.isCancellable()) {
|
||||
subagent.orchestrator.cancel();
|
||||
auditLogger?.subagentLifecycle?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'cancel',
|
||||
queue_mode: subagent.queueMode,
|
||||
reason: 'interrupt_mode_latest_wins',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
subagent.pending.push(queued);
|
||||
auditLogger?.subagentTurn?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'queued',
|
||||
request_id: requestId,
|
||||
queue_mode: subagent.queueMode,
|
||||
pending_count: subagent.pending.length,
|
||||
});
|
||||
this.processQueue(subagent);
|
||||
});
|
||||
}
|
||||
|
||||
cancel(subagentId: string): boolean {
|
||||
@@ -196,6 +279,14 @@ export class SubagentManager {
|
||||
}
|
||||
subagent.orchestrator.cancel();
|
||||
subagent.updatedAt = Date.now();
|
||||
auditLogger?.subagentLifecycle?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'cancel',
|
||||
queue_mode: subagent.queueMode,
|
||||
reason: 'explicit_cancel',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -209,10 +300,27 @@ export class SubagentManager {
|
||||
subagent.orchestrator.cancel();
|
||||
}
|
||||
|
||||
while (subagent.pending.length > 0) {
|
||||
const queued = subagent.pending.shift();
|
||||
if (!queued) {
|
||||
continue;
|
||||
}
|
||||
queued.reject(new Error(`Subagent session "${subagent.id}" was deleted.`));
|
||||
}
|
||||
|
||||
const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, subagent.sessionUserId);
|
||||
session.clear();
|
||||
this.config.sessionManager.closeSession(SUBAGENT_FRONTEND, subagent.sessionUserId);
|
||||
this.sessions.delete(subagentId);
|
||||
|
||||
auditLogger?.subagentLifecycle?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'delete',
|
||||
queue_mode: subagent.queueMode,
|
||||
reason: 'explicit_delete',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -235,6 +343,15 @@ export class SubagentManager {
|
||||
: history.length;
|
||||
const tail = history.slice(Math.max(0, history.length - max));
|
||||
const messages = tail.map((entry) => this.toTranscriptEntry(entry));
|
||||
|
||||
auditLogger?.subagentLifecycle?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'summary',
|
||||
queue_mode: subagent.queueMode,
|
||||
});
|
||||
|
||||
return {
|
||||
session: this.getSummary(subagent),
|
||||
messages,
|
||||
@@ -247,7 +364,7 @@ export class SubagentManager {
|
||||
}
|
||||
const removed: string[] = [];
|
||||
for (const [id, session] of this.sessions.entries()) {
|
||||
if (session.busy) {
|
||||
if (session.busy || session.pending.length > 0) {
|
||||
continue;
|
||||
}
|
||||
if ((nowMs - session.updatedAt) <= this.config.idleTtlMs) {
|
||||
@@ -255,6 +372,14 @@ export class SubagentManager {
|
||||
}
|
||||
this.delete(id);
|
||||
removed.push(id);
|
||||
auditLogger?.subagentLifecycle?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: session.id,
|
||||
trace_id: session.traceId,
|
||||
action: 'ttl_evict',
|
||||
queue_mode: session.queueMode,
|
||||
reason: 'idle_ttl_elapsed',
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -271,7 +396,7 @@ export class SubagentManager {
|
||||
const normalized = id.trim();
|
||||
const subagent = this.sessions.get(normalized);
|
||||
if (!subagent) {
|
||||
throw new Error(`Subagent session \"${normalized}\" not found.`);
|
||||
throw new Error(`Subagent session "${normalized}" not found.`);
|
||||
}
|
||||
return subagent;
|
||||
}
|
||||
@@ -287,7 +412,12 @@ export class SubagentManager {
|
||||
id: subagent.id,
|
||||
agent: subagent.agent,
|
||||
tier: subagent.tier,
|
||||
queueMode: subagent.queueMode,
|
||||
toolProfile: subagent.toolProfile,
|
||||
traceId: subagent.traceId,
|
||||
messageCount: session.getHistory().length,
|
||||
completedTurns: subagent.completedTurns,
|
||||
pendingCount: subagent.pending.length,
|
||||
createdAt: subagent.createdAt,
|
||||
updatedAt: subagent.updatedAt,
|
||||
busy: subagent.busy,
|
||||
@@ -301,4 +431,131 @@ export class SubagentManager {
|
||||
timestamp: entry.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private async processQueue(subagent: ManagedSubagent): Promise<void> {
|
||||
if (subagent.processing) {
|
||||
return;
|
||||
}
|
||||
subagent.processing = true;
|
||||
|
||||
try {
|
||||
while (subagent.pending.length > 0) {
|
||||
const next = subagent.pending.shift();
|
||||
if (!next) {
|
||||
continue;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
subagent.busy = true;
|
||||
subagent.updatedAt = startedAt;
|
||||
|
||||
try {
|
||||
this.assertBudgets(subagent);
|
||||
auditLogger?.subagentTurn?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'start',
|
||||
request_id: next.requestId,
|
||||
queue_mode: subagent.queueMode,
|
||||
input_chars: next.message.length,
|
||||
pending_count: subagent.pending.length,
|
||||
});
|
||||
|
||||
const content = await this.runTurnWithTimeout(subagent, next.message);
|
||||
subagent.completedTurns += 1;
|
||||
subagent.updatedAt = Date.now();
|
||||
const totalTokens = this.getTotalTokens(subagent);
|
||||
|
||||
auditLogger?.subagentTurn?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'complete',
|
||||
request_id: next.requestId,
|
||||
queue_mode: subagent.queueMode,
|
||||
duration_ms: subagent.updatedAt - startedAt,
|
||||
output_chars: content.length,
|
||||
turn_count: subagent.completedTurns,
|
||||
total_tokens: totalTokens,
|
||||
pending_count: subagent.pending.length,
|
||||
});
|
||||
|
||||
next.resolve({
|
||||
content,
|
||||
session: this.getSummary(subagent),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
subagent.updatedAt = Date.now();
|
||||
auditLogger?.subagentTurn?.({
|
||||
parent_session_id: this.config.parentSessionId,
|
||||
subagent_id: subagent.id,
|
||||
trace_id: subagent.traceId,
|
||||
action: 'error',
|
||||
request_id: next.requestId,
|
||||
queue_mode: subagent.queueMode,
|
||||
duration_ms: subagent.updatedAt - startedAt,
|
||||
error: message,
|
||||
pending_count: subagent.pending.length,
|
||||
});
|
||||
next.reject(error instanceof Error ? error : new Error(message));
|
||||
} finally {
|
||||
subagent.busy = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
subagent.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runTurnWithTimeout(subagent: ManagedSubagent, message: string): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (subagent.orchestrator.isCancellable()) {
|
||||
subagent.orchestrator.cancel();
|
||||
}
|
||||
reject(new Error(`Subagent turn timed out after ${this.config.turnTimeoutMs}ms`));
|
||||
}, this.config.turnTimeoutMs);
|
||||
|
||||
subagent.orchestrator.process(message)
|
||||
.then((result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private assertBudgets(subagent: ManagedSubagent): void {
|
||||
if (subagent.completedTurns >= this.config.maxTurns) {
|
||||
throw new Error(`Subagent session "${subagent.id}" reached max turns (${this.config.maxTurns}).`);
|
||||
}
|
||||
if (this.getTotalTokens(subagent) >= this.config.maxTotalTokens) {
|
||||
throw new Error(`Subagent session "${subagent.id}" reached max total tokens (${this.config.maxTotalTokens}).`);
|
||||
}
|
||||
}
|
||||
|
||||
private getTotalTokens(subagent: ManagedSubagent): number {
|
||||
const usage = subagent.orchestrator.getUsage();
|
||||
return usage.total.inputTokens + usage.total.outputTokens;
|
||||
}
|
||||
|
||||
private buildSubagentToolRegistry(profile: ToolProfile): ToolRegistry {
|
||||
const subagentToolRegistry = this.config.toolRegistry.clone();
|
||||
if (profile !== 'full') {
|
||||
const allowed = PROFILE_TOOLS[profile];
|
||||
for (const tool of subagentToolRegistry.list()) {
|
||||
if (!allowed.has(tool.name)) {
|
||||
subagentToolRegistry.unregister(tool.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const toolName of BLOCKED_SUBAGENT_TOOL_NAMES) {
|
||||
subagentToolRegistry.unregister(toolName);
|
||||
}
|
||||
return subagentToolRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { createApproveCommand, createApprovalsCommand, createBackendCommand, createContextCommand, createCouncilCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createSkillCommand, createStopCommand, createToolsCommand, createTransferCommand } from './index.js';
|
||||
import { createApproveCommand, createApprovalsCommand, createBackendCommand, createContextCommand, createCouncilCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createSkillCommand, createStopCommand, createSubagentsCommand, createToolsCommand, createTransferCommand } from './index.js';
|
||||
|
||||
describe('builtin /model command', () => {
|
||||
it('passes through the full argument string', async () => {
|
||||
@@ -94,6 +94,34 @@ describe('builtin /council command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('builtin /subagents command', () => {
|
||||
it('passes through raw subcommands', async () => {
|
||||
const cmd = createSubagentsCommand();
|
||||
const subagentsCommand = vi.fn(() => 'subagents listed');
|
||||
const result = await cmd.execute(['summary', 'planner', '10'], {
|
||||
channel: 'test',
|
||||
senderId: 'user',
|
||||
sessionId: 's1',
|
||||
rawInput: '/subagents summary planner 10',
|
||||
services: { subagentsCommand },
|
||||
});
|
||||
expect(subagentsCommand).toHaveBeenCalledWith('summary planner 10');
|
||||
expect(result).toEqual({ handled: true, text: 'subagents listed' });
|
||||
});
|
||||
|
||||
it('returns not-available when service is missing', async () => {
|
||||
const cmd = createSubagentsCommand();
|
||||
const result = await cmd.execute([], {
|
||||
channel: 'test',
|
||||
senderId: 'user',
|
||||
sessionId: 's1',
|
||||
rawInput: '/subagents',
|
||||
services: {},
|
||||
});
|
||||
expect(result).toEqual({ handled: true, text: 'Subagents command is not available in this session.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('builtin /elevate command', () => {
|
||||
it('passes through the full argument string', async () => {
|
||||
const cmd = createElevateCommand();
|
||||
|
||||
@@ -274,6 +274,22 @@ export function createCouncilCommand(): CommandDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubagentsCommand(): CommandDefinition {
|
||||
return {
|
||||
name: 'subagents',
|
||||
description: 'Inspect subagent sessions (list/summary/cancel/delete)',
|
||||
execute: async (args, ctx) => {
|
||||
if (!ctx.services?.subagentsCommand) {
|
||||
return notAvailable('Subagents command');
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
text: await ctx.services.subagentsCommand(args.join(' ').trim()),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTransferCommand(): CommandDefinition {
|
||||
return {
|
||||
name: 'transfer',
|
||||
@@ -381,6 +397,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
|
||||
registry.register(createContextCommand());
|
||||
registry.register(createResearchCommand());
|
||||
registry.register(createCouncilCommand());
|
||||
registry.register(createSubagentsCommand());
|
||||
registry.register(createModelCommand());
|
||||
registry.register(createCompactCommand());
|
||||
registry.register(createResetCommand());
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface CommandServices {
|
||||
reset?: () => Promise<string> | string;
|
||||
delegateAgent?: (agentName: string, task: string) => Promise<string> | string;
|
||||
runCouncil?: (task: string) => Promise<string> | string;
|
||||
subagentsCommand?: (input: string) => Promise<string> | string;
|
||||
|
||||
getElevation?: () => Promise<string> | string;
|
||||
setElevation?: (input: string) => Promise<string> | string;
|
||||
|
||||
@@ -1701,6 +1701,11 @@ describe('configSchema — agents truthfulness/autonomy', () => {
|
||||
expect(result.agents.subagents.enabled).toBe(true);
|
||||
expect(result.agents.subagents.max_active_sessions).toBe(6);
|
||||
expect(result.agents.subagents.idle_ttl_ms).toBe(3600000);
|
||||
expect(result.agents.subagents.queue_mode).toBe('followup');
|
||||
expect(result.agents.subagents.default_tool_profile).toBe('minimal');
|
||||
expect(result.agents.subagents.max_turns).toBe(40);
|
||||
expect(result.agents.subagents.max_total_tokens).toBe(200000);
|
||||
expect(result.agents.subagents.turn_timeout_ms).toBe(120000);
|
||||
expect(result.agents.immutable_denylist).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ tool: 'shell.exec', args_pattern: 'git push origin main' }),
|
||||
@@ -1720,6 +1725,11 @@ describe('configSchema — agents truthfulness/autonomy', () => {
|
||||
enabled: false,
|
||||
max_active_sessions: 3,
|
||||
idle_ttl_ms: 120000,
|
||||
queue_mode: 'interrupt',
|
||||
default_tool_profile: 'messaging',
|
||||
max_turns: 12,
|
||||
max_total_tokens: 50000,
|
||||
turn_timeout_ms: 90000,
|
||||
},
|
||||
immutable_denylist: [
|
||||
{ tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' },
|
||||
@@ -1733,6 +1743,11 @@ describe('configSchema — agents truthfulness/autonomy', () => {
|
||||
expect(result.agents.subagents.enabled).toBe(false);
|
||||
expect(result.agents.subagents.max_active_sessions).toBe(3);
|
||||
expect(result.agents.subagents.idle_ttl_ms).toBe(120000);
|
||||
expect(result.agents.subagents.queue_mode).toBe('interrupt');
|
||||
expect(result.agents.subagents.default_tool_profile).toBe('messaging');
|
||||
expect(result.agents.subagents.max_turns).toBe(12);
|
||||
expect(result.agents.subagents.max_total_tokens).toBe(50000);
|
||||
expect(result.agents.subagents.turn_timeout_ms).toBe(90000);
|
||||
expect(result.agents.immutable_denylist).toEqual([
|
||||
{ tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' },
|
||||
]);
|
||||
@@ -1760,6 +1775,17 @@ describe('configSchema — agents truthfulness/autonomy', () => {
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid subagent queue mode', () => {
|
||||
expect(() => configSchema.parse({
|
||||
...minimalConfig,
|
||||
agents: {
|
||||
subagents: {
|
||||
queue_mode: 'latest',
|
||||
},
|
||||
},
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid truthfulness_mode', () => {
|
||||
expect(() => configSchema.parse({
|
||||
...minimalConfig,
|
||||
|
||||
@@ -539,6 +539,11 @@ const agentsSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
max_active_sessions: z.number().min(1).max(32).default(6),
|
||||
idle_ttl_ms: z.number().min(60_000).max(86_400_000).default(3_600_000),
|
||||
queue_mode: z.enum(['followup', 'interrupt']).default('followup'),
|
||||
default_tool_profile: z.enum(['minimal', 'messaging', 'coding', 'full']).default('minimal'),
|
||||
max_turns: z.number().min(1).max(500).default(40),
|
||||
max_total_tokens: z.number().min(1000).max(5_000_000).default(200_000),
|
||||
turn_timeout_ms: z.number().min(1000).max(10 * 60 * 1000).default(120_000),
|
||||
}).default({}),
|
||||
auto_escalate: z.boolean().default(false),
|
||||
max_delegation_depth: z.number().min(1).max(10).default(3),
|
||||
|
||||
@@ -737,6 +737,77 @@ describe('daemon command fast-path integration', () => {
|
||||
expect(session.setConfig).toHaveBeenCalledWith('queue.mode', 'followup');
|
||||
});
|
||||
|
||||
it('handles /subagents list via command fast-path', async () => {
|
||||
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process');
|
||||
const session = {
|
||||
id: 'telegram:user-subagents',
|
||||
addMessage: vi.fn(),
|
||||
getHistory: vi.fn(() => []),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
getConfig: vi.fn(() => undefined),
|
||||
setConfig: vi.fn(),
|
||||
deleteConfig: vi.fn(),
|
||||
};
|
||||
|
||||
const commandRegistry = new CommandRegistry();
|
||||
registerBuiltinCommands(commandRegistry);
|
||||
|
||||
const agentConfigRegistry = new AgentConfigRegistry();
|
||||
agentConfigRegistry.loadFromConfig({
|
||||
assistant: { model_tier: 'default', sandbox: false },
|
||||
helper: { model_tier: 'fast', sandbox: false },
|
||||
});
|
||||
|
||||
const router = createMessageRouter({
|
||||
sessionManager: {
|
||||
getSession: vi.fn(() => session),
|
||||
} as unknown as MessageRouterDeps['sessionManager'],
|
||||
modelRouter: {
|
||||
getAvailableTiers: () => ['fast', 'default', 'complex', 'local'],
|
||||
getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }),
|
||||
getLabel: (tier: string) => tier,
|
||||
} as unknown as MessageRouterDeps['modelRouter'],
|
||||
systemPrompt: 'test prompt',
|
||||
toolRegistry: {
|
||||
clone() { return this; },
|
||||
register: vi.fn(),
|
||||
} as unknown as MessageRouterDeps['toolRegistry'],
|
||||
toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'],
|
||||
config: {
|
||||
agents: {
|
||||
primary_tier: 'default',
|
||||
delegation: {
|
||||
compaction: 'fast',
|
||||
memory_extraction: 'fast',
|
||||
classification: 'fast',
|
||||
tool_summarisation: 'fast',
|
||||
complex_reasoning: 'complex',
|
||||
},
|
||||
max_delegation_depth: 3,
|
||||
max_iterations: 10,
|
||||
},
|
||||
compaction: { enabled: false },
|
||||
models: { default: { provider: 'anthropic', model: 'claude' } },
|
||||
} as unknown as MessageRouterDeps['config'],
|
||||
commandRegistry,
|
||||
agentConfigRegistry,
|
||||
});
|
||||
|
||||
const reply = vi.fn(async (_message: OutboundMessage) => {});
|
||||
await router.handler({
|
||||
id: 'subagents-1',
|
||||
channel: 'telegram',
|
||||
senderId: 'user-subagents',
|
||||
text: '/subagents list',
|
||||
timestamp: Date.now(),
|
||||
metadata: { isCommand: true, command: 'subagents', commandArgs: 'list' },
|
||||
} as MessageRouterInput, reply);
|
||||
|
||||
expect(processSpy).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'No active subagent sessions.' }));
|
||||
});
|
||||
|
||||
it('uses intent match to override agent target', async () => {
|
||||
const session = {
|
||||
id: 'telegram:user-2',
|
||||
|
||||
@@ -695,8 +695,13 @@ export function createMessageRouter(deps: {
|
||||
delegation: delegationConfig,
|
||||
maxDelegationDepth: deps.config.agents.max_delegation_depth ?? 3,
|
||||
defaultPrimaryTier: effectiveTier,
|
||||
defaultQueueMode: deps.config.agents.subagents?.queue_mode ?? 'followup',
|
||||
defaultToolProfile: deps.config.agents.subagents?.default_tool_profile ?? 'minimal',
|
||||
maxIterations: deps.config.agents.max_iterations,
|
||||
maxActiveSessions: maxSubagentSessions,
|
||||
maxTurns: deps.config.agents.subagents?.max_turns ?? 40,
|
||||
maxTotalTokens: deps.config.agents.subagents?.max_total_tokens ?? 200_000,
|
||||
turnTimeoutMs: deps.config.agents.subagents?.turn_timeout_ms ?? 120_000,
|
||||
idleTtlMs: deps.config.agents.subagents?.idle_ttl_ms ?? 3_600_000,
|
||||
});
|
||||
for (const tool of createSubagentTools(subagentManager)) {
|
||||
@@ -1274,6 +1279,58 @@ export function createMessageRouter(deps: {
|
||||
}
|
||||
return result.output;
|
||||
},
|
||||
subagentsCommand: async (input: string) => {
|
||||
if (!subagentManager) {
|
||||
return 'Subagents are not enabled for this session.';
|
||||
}
|
||||
const raw = input.trim();
|
||||
if (!raw || raw === 'list') {
|
||||
const entries = subagentManager.list();
|
||||
if (entries.length === 0) {
|
||||
return 'No active subagent sessions.';
|
||||
}
|
||||
return [
|
||||
`Active subagents (${entries.length}):`,
|
||||
...entries.map((entry) => (
|
||||
`- ${entry.id} agent=${entry.agent} tier=${entry.tier} queue=${entry.queueMode} profile=${entry.toolProfile} ` +
|
||||
`turns=${entry.completedTurns} pending=${entry.pendingCount} busy=${entry.busy ? 'yes' : 'no'}`
|
||||
)),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const [action, ...rest] = raw.split(/\s+/);
|
||||
if ((action === 'summary' || action === 'show') && rest.length >= 1) {
|
||||
const subagentId = rest[0];
|
||||
const limitRaw = rest[1];
|
||||
const parsedLimit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
|
||||
const transcript = subagentManager.getTranscript(subagentId, Number.isFinite(parsedLimit) ? parsedLimit : undefined);
|
||||
return [
|
||||
`Subagent ${transcript.session.id} summary:`,
|
||||
`- agent=${transcript.session.agent} tier=${transcript.session.tier} queue=${transcript.session.queueMode} profile=${transcript.session.toolProfile}`,
|
||||
`- turns=${transcript.session.completedTurns} messages=${transcript.session.messageCount} pending=${transcript.session.pendingCount}`,
|
||||
'Transcript:',
|
||||
...(transcript.messages.length > 0
|
||||
? transcript.messages.map((entry, idx) => `${idx + 1}. [${entry.role}] ${entry.content.slice(0, 200)}`)
|
||||
: ['(empty)']),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (action === 'cancel' && rest.length >= 1) {
|
||||
const cancelled = subagentManager.cancel(rest[0]);
|
||||
return cancelled
|
||||
? `Cancellation requested for subagent \"${rest[0]}\".`
|
||||
: `No active operation to cancel for subagent \"${rest[0]}\".`;
|
||||
}
|
||||
|
||||
if ((action === 'delete' || action === 'rm') && rest.length >= 1) {
|
||||
const deleted = subagentManager.delete(rest[0]);
|
||||
return deleted
|
||||
? `Deleted subagent session \"${rest[0]}\".`
|
||||
: `Subagent session \"${rest[0]}\" not found.`;
|
||||
}
|
||||
|
||||
return 'Usage: /subagents [list|summary <id> [limit]|cancel <id>|delete <id>]';
|
||||
},
|
||||
|
||||
getElevation: () => {
|
||||
return getElevationStatusMessage({
|
||||
|
||||
@@ -31,6 +31,7 @@ const SLASH_COMMANDS = [
|
||||
{ name: '/usage', desc: 'Show token usage' },
|
||||
{ name: '/status', desc: 'Show system health' },
|
||||
{ name: '/model', desc: 'Show current model' },
|
||||
{ name: '/subagents', desc: 'Inspect subagent sessions' },
|
||||
{ name: '/stop', desc: 'Stop active response' },
|
||||
{ name: '/cancel', desc: 'Alias for /stop' },
|
||||
{ name: '/approvals', desc: 'List pending guarded actions' },
|
||||
|
||||
@@ -25,7 +25,12 @@ describe('subagent tools', () => {
|
||||
id: 'planner',
|
||||
agent: 'research',
|
||||
tier: 'complex',
|
||||
queueMode: 'followup',
|
||||
toolProfile: 'minimal',
|
||||
traceId: 'trace-planner',
|
||||
messageCount: 0,
|
||||
completedTurns: 0,
|
||||
pendingCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
busy: false,
|
||||
@@ -36,7 +41,12 @@ describe('subagent tools', () => {
|
||||
id: 'planner',
|
||||
agent: 'research',
|
||||
tier: 'complex',
|
||||
queueMode: 'followup',
|
||||
toolProfile: 'minimal',
|
||||
traceId: 'trace-planner',
|
||||
messageCount: 2,
|
||||
completedTurns: 1,
|
||||
pendingCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
busy: false,
|
||||
@@ -60,6 +70,8 @@ describe('subagent tools', () => {
|
||||
agent: 'research',
|
||||
subagentId: 'planner',
|
||||
tier: undefined,
|
||||
queueMode: undefined,
|
||||
toolProfile: undefined,
|
||||
systemPrompt: undefined,
|
||||
});
|
||||
expect(mockController.send).toHaveBeenCalledWith('planner', 'Create a checklist');
|
||||
@@ -72,7 +84,12 @@ describe('subagent tools', () => {
|
||||
id: 'planner',
|
||||
agent: 'research',
|
||||
tier: 'complex',
|
||||
queueMode: 'followup',
|
||||
toolProfile: 'minimal',
|
||||
traceId: 'trace-planner',
|
||||
messageCount: 4,
|
||||
completedTurns: 2,
|
||||
pendingCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 3,
|
||||
busy: false,
|
||||
@@ -83,7 +100,12 @@ describe('subagent tools', () => {
|
||||
id: 'planner',
|
||||
agent: 'research',
|
||||
tier: 'complex',
|
||||
queueMode: 'followup',
|
||||
toolProfile: 'minimal',
|
||||
traceId: 'trace-planner',
|
||||
messageCount: 4,
|
||||
completedTurns: 2,
|
||||
pendingCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 3,
|
||||
busy: false,
|
||||
@@ -96,7 +118,12 @@ describe('subagent tools', () => {
|
||||
id: 'planner',
|
||||
agent: 'research',
|
||||
tier: 'complex',
|
||||
queueMode: 'followup',
|
||||
toolProfile: 'minimal',
|
||||
traceId: 'trace-planner',
|
||||
messageCount: 4,
|
||||
completedTurns: 2,
|
||||
pendingCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 3,
|
||||
busy: false,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
import type { ToolProfile } from '../../config/schema.js';
|
||||
import type { ModelTier } from '../../models/router.js';
|
||||
|
||||
interface SubagentSessionSummary {
|
||||
id: string;
|
||||
agent: string;
|
||||
tier: ModelTier;
|
||||
queueMode: 'followup' | 'interrupt';
|
||||
toolProfile: ToolProfile;
|
||||
traceId: string;
|
||||
messageCount: number;
|
||||
completedTurns: number;
|
||||
pendingCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
busy: boolean;
|
||||
@@ -17,6 +23,8 @@ interface SubagentController {
|
||||
subagentId?: string;
|
||||
tier?: ModelTier;
|
||||
systemPrompt?: string;
|
||||
queueMode?: 'followup' | 'interrupt';
|
||||
toolProfile?: ToolProfile;
|
||||
}): SubagentSessionSummary;
|
||||
send(subagentId: string, message: string): Promise<{
|
||||
content: string;
|
||||
@@ -40,6 +48,8 @@ interface SpawnArgs {
|
||||
subagent_id?: string;
|
||||
tier?: ModelTier;
|
||||
system_prompt?: string;
|
||||
queue_mode?: 'followup' | 'interrupt';
|
||||
tool_profile?: ToolProfile;
|
||||
task?: string;
|
||||
}
|
||||
|
||||
@@ -62,7 +72,12 @@ function formatSummary(summary: SubagentSessionSummary): string {
|
||||
`id=${summary.id}`,
|
||||
`agent=${summary.agent}`,
|
||||
`tier=${summary.tier}`,
|
||||
`queue=${summary.queueMode}`,
|
||||
`profile=${summary.toolProfile}`,
|
||||
`trace=${summary.traceId}`,
|
||||
`turns=${summary.completedTurns}`,
|
||||
`messages=${summary.messageCount}`,
|
||||
`pending=${summary.pendingCount}`,
|
||||
`busy=${summary.busy ? 'yes' : 'no'}`,
|
||||
].join(' ');
|
||||
}
|
||||
@@ -81,6 +96,8 @@ export function createSubagentTools(controller: SubagentController): Tool[] {
|
||||
agent: { type: 'string', description: 'Agent profile name from agent_configs (e.g. research, coder).' },
|
||||
subagent_id: { type: 'string', description: 'Optional custom subagent session ID.' },
|
||||
tier: { type: 'string', description: 'Optional model tier override (fast|default|complex|local).' },
|
||||
queue_mode: { type: 'string', description: 'Optional queue mode override (followup|interrupt).' },
|
||||
tool_profile: { type: 'string', description: 'Optional tool profile override (minimal|messaging|coding|full).' },
|
||||
system_prompt: { type: 'string', description: 'Optional system prompt override for this subagent session.' },
|
||||
task: { type: 'string', description: 'Optional initial task to run right after spawn.' },
|
||||
},
|
||||
@@ -93,6 +110,8 @@ export function createSubagentTools(controller: SubagentController): Tool[] {
|
||||
agent: args.agent,
|
||||
subagentId: args.subagent_id,
|
||||
tier: args.tier,
|
||||
queueMode: args.queue_mode,
|
||||
toolProfile: args.tool_profile,
|
||||
systemPrompt: args.system_prompt,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user