Skip to content

Commit f4ecbc5

Browse files
committed
feat(routing): add agent role normalization for proper history routing
- Add normalizeAgentRole() to handle subagent patterns (subagents/architect → architect) - Add tool.execute.before hook to capture subagent_type from Task tool args - Fix case-sensitivity bug (task vs Task) in logToolExecution - Add comprehensive unit tests for agent role normalization (20 tests) Routes subagent outputs to correct history directories: - architect → history/decisions/ - researcher/analyst/explorer → history/research/ - engineer/designer → history/execution/ Closes agent routing issue where all outputs went to sessions/
1 parent bd8637c commit f4ecbc5

File tree

7 files changed

+381
-11
lines changed

7 files changed

+381
-11
lines changed

dist/index.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ export const PAIPlugin = async ({ worktree }) => {
9797
const messageTextCache = new Map();
9898
// Track which messages we've already processed for archival (deduplication)
9999
const processedMessageIds = new Set();
100+
// Track pending Task tool calls to capture subagent_type
101+
// Key: callID, Value: subagent_type
102+
const pendingTaskCalls = new Map();
100103
// Auto-initialize PAI infrastructure if needed
101104
ensurePAIStructure();
102105
// Load CORE skill content from $PAI_DIR/skill/core/SKILL.md
@@ -173,9 +176,14 @@ export const PAIPlugin = async ({ worktree }) => {
173176
const file = props?.input?.file_path?.split('/').pop() || 'file';
174177
process.stderr.write(`\x1b]0;Editing ${file}...\x07`);
175178
}
176-
else if (props?.tool === 'Task') {
179+
else if (props?.tool === 'Task' || props?.tool === 'task') {
177180
const type = props?.input?.subagent_type || 'agent';
178181
process.stderr.write(`\x1b]0;Agent: ${type}...\x07`);
182+
// Cache the subagent_type for when tool.execute.after fires
183+
const callId = props?.id || props?.callId || props?.call_id;
184+
if (callId && props?.input?.subagent_type) {
185+
pendingTaskCalls.set(callId, props.input.subagent_type);
186+
}
179187
}
180188
}
181189
// Handle assistant message completion (Tab Titles & Artifact Archival)
@@ -232,12 +240,30 @@ export const PAIPlugin = async ({ worktree }) => {
232240
// (In practice, messages are cleaned up after processing)
233241
}
234242
},
243+
"tool.execute.before": async (input, output) => {
244+
// Cache subagent_type from Task tool args for later use in tool.execute.after
245+
if ((input.tool === 'Task' || input.tool === 'task') && input.callID) {
246+
const args = output.args;
247+
if (args?.subagent_type) {
248+
pendingTaskCalls.set(input.callID, args.subagent_type);
249+
}
250+
}
251+
},
235252
"tool.execute.after": async (input, output) => {
236253
const sessionId = input.sessionID;
237254
if (sessionId) {
238255
if (!loggers.has(sessionId)) {
239256
loggers.set(sessionId, new Logger(sessionId, worktree));
240257
}
258+
// For Task tools, inject the cached subagent_type into metadata
259+
if ((input.tool === 'Task' || input.tool === 'task') && input.callID) {
260+
const cachedAgentType = pendingTaskCalls.get(input.callID);
261+
if (cachedAgentType) {
262+
output.metadata = output.metadata || {};
263+
output.metadata.subagent_type = cachedAgentType;
264+
pendingTaskCalls.delete(input.callID);
265+
}
266+
}
241267
loggers.get(sessionId).logToolExecution(input, output);
242268
}
243269
},

dist/lib/logger.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ export declare class Logger {
2828
generateSessionSummary(): Promise<string | null>;
2929
private parseStructuredResponse;
3030
private isLearningCapture;
31+
/**
32+
* Normalize agent role from subagent_type patterns to base role.
33+
* Handles patterns like:
34+
* - "subagents/researcher-claude" → "researcher"
35+
* - "subagents/sparc-architect" → "architect"
36+
* - "subagents/sparc-dev" → "engineer"
37+
* - "researcher" → "researcher" (passthrough)
38+
*/
39+
private normalizeAgentRole;
3140
private determineArtifactType;
3241
private createArtifact;
3342
logError(context: string, error: any): void;

dist/lib/logger.js

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class Logger {
126126
const sessionId = this.sessionId;
127127
this.toolsUsed.add(toolName);
128128
const metadata = output.metadata || {};
129-
if (toolName === 'Task' && metadata?.subagent_type) {
129+
if ((toolName === 'Task' || toolName === 'task') && metadata?.subagent_type) {
130130
this.setAgentForSession(sessionId, metadata.subagent_type);
131131
}
132132
else if (toolName === 'subagent_stop' || toolName === 'stop') {
@@ -241,13 +241,56 @@ This session summary was automatically generated by the PAI OpenCode Plugin.
241241
count++;
242242
return count >= 2;
243243
}
244+
/**
245+
* Normalize agent role from subagent_type patterns to base role.
246+
* Handles patterns like:
247+
* - "subagents/researcher-claude" → "researcher"
248+
* - "subagents/sparc-architect" → "architect"
249+
* - "subagents/sparc-dev" → "engineer"
250+
* - "researcher" → "researcher" (passthrough)
251+
*/
252+
normalizeAgentRole(agentRole) {
253+
if (!agentRole)
254+
return 'pai';
255+
// Remove "subagents/" prefix if present (case-insensitive)
256+
let role = agentRole.replace(/^subagents\//i, '').toLowerCase();
257+
// Role keywords to look for anywhere in the string
258+
// Order matters: more specific patterns first
259+
const roleKeywords = [
260+
// Researcher patterns (prefix or contains)
261+
[/researcher/i, 'researcher'],
262+
[/research/i, 'researcher'],
263+
// Architect patterns
264+
[/architect/i, 'architect'],
265+
// Engineer patterns (includes "dev" for sparc-dev)
266+
[/engineer/i, 'engineer'],
267+
[/\bdev\b/i, 'engineer'], // "sparc-dev" → engineer
268+
// Designer patterns
269+
[/designer/i, 'designer'],
270+
// Security patterns
271+
[/pentester/i, 'pentester'],
272+
// These map to researcher
273+
[/analyst/i, 'researcher'],
274+
[/explorer/i, 'researcher'],
275+
[/^explore$/i, 'researcher'],
276+
[/^intern$/i, 'researcher'],
277+
];
278+
for (const [pattern, normalized] of roleKeywords) {
279+
if (pattern.test(role)) {
280+
return normalized;
281+
}
282+
}
283+
// Return lowercase role if no pattern matched
284+
return role;
285+
}
244286
determineArtifactType(agentRole, isLearning, sections) {
245287
const summary = (sections['SUMMARY'] || '').toLowerCase();
246-
if (agentRole === 'architect')
288+
const normalizedRole = this.normalizeAgentRole(agentRole);
289+
if (normalizedRole === 'architect')
247290
return 'DECISION';
248-
if (agentRole === 'researcher' || agentRole === 'pentester')
291+
if (normalizedRole === 'researcher' || normalizedRole === 'pentester')
249292
return 'RESEARCH';
250-
if (agentRole === 'engineer' || agentRole === 'designer') {
293+
if (normalizedRole === 'engineer' || normalizedRole === 'designer') {
251294
if (summary.includes('fix') || summary.includes('bug') || summary.includes('issue'))
252295
return 'BUG';
253296
if (summary.includes('refactor') || summary.includes('improve') || summary.includes('cleanup'))

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@fpr1m3/opencode-pai-plugin",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"type": "module",
55
"description": "Personal AI Infrastructure (PAI) plugin for OpenCode",
66
"main": "dist/index.js",

src/index.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export const PAIPlugin: Plugin = async ({ worktree }) => {
112112
// Track which messages we've already processed for archival (deduplication)
113113
const processedMessageIds = new Set<string>();
114114

115+
// Track pending Task tool calls to capture subagent_type
116+
// Key: callID, Value: subagent_type
117+
const pendingTaskCalls = new Map<string, string>();
118+
115119
// Auto-initialize PAI infrastructure if needed
116120
ensurePAIStructure();
117121

@@ -196,9 +200,14 @@ export const PAIPlugin: Plugin = async ({ worktree }) => {
196200
} else if (props?.tool === 'Edit' || props?.tool === 'Write') {
197201
const file = props?.input?.file_path?.split('/').pop() || 'file';
198202
process.stderr.write(`\x1b]0;Editing ${file}...\x07`);
199-
} else if (props?.tool === 'Task') {
203+
} else if (props?.tool === 'Task' || props?.tool === 'task') {
200204
const type = props?.input?.subagent_type || 'agent';
201205
process.stderr.write(`\x1b]0;Agent: ${type}...\x07`);
206+
// Cache the subagent_type for when tool.execute.after fires
207+
const callId = props?.id || props?.callId || props?.call_id;
208+
if (callId && props?.input?.subagent_type) {
209+
pendingTaskCalls.set(callId, props.input.subagent_type);
210+
}
202211
}
203212
}
204213

@@ -261,12 +270,33 @@ export const PAIPlugin: Plugin = async ({ worktree }) => {
261270
}
262271
},
263272

273+
"tool.execute.before": async (input, output) => {
274+
// Cache subagent_type from Task tool args for later use in tool.execute.after
275+
if ((input.tool === 'Task' || input.tool === 'task') && input.callID) {
276+
const args = output.args as any;
277+
if (args?.subagent_type) {
278+
pendingTaskCalls.set(input.callID, args.subagent_type);
279+
}
280+
}
281+
},
282+
264283
"tool.execute.after": async (input, output) => {
265284
const sessionId = input.sessionID;
266285
if (sessionId) {
267286
if (!loggers.has(sessionId)) {
268287
loggers.set(sessionId, new Logger(sessionId, worktree));
269288
}
289+
290+
// For Task tools, inject the cached subagent_type into metadata
291+
if ((input.tool === 'Task' || input.tool === 'task') && input.callID) {
292+
const cachedAgentType = pendingTaskCalls.get(input.callID);
293+
if (cachedAgentType) {
294+
output.metadata = output.metadata || {};
295+
output.metadata.subagent_type = cachedAgentType;
296+
pendingTaskCalls.delete(input.callID);
297+
}
298+
}
299+
270300
loggers.get(sessionId)!.logToolExecution(input, output);
271301
}
272302
},

src/lib/logger.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export class Logger {
144144
this.toolsUsed.add(toolName);
145145
const metadata = output.metadata || {};
146146

147-
if (toolName === 'Task' && metadata?.subagent_type) {
147+
if ((toolName === 'Task' || toolName === 'task') && metadata?.subagent_type) {
148148
this.setAgentForSession(sessionId, metadata.subagent_type);
149149
} else if (toolName === 'subagent_stop' || toolName === 'stop') {
150150
this.setAgentForSession(sessionId, 'pai');
@@ -260,11 +260,64 @@ This session summary was automatically generated by the PAI OpenCode Plugin.
260260
return count >= 2;
261261
}
262262

263+
/**
264+
* Normalize agent role from subagent_type patterns to base role.
265+
* Handles patterns like:
266+
* - "subagents/researcher-claude" → "researcher"
267+
* - "subagents/sparc-architect" → "architect"
268+
* - "subagents/sparc-dev" → "engineer"
269+
* - "researcher" → "researcher" (passthrough)
270+
*/
271+
private normalizeAgentRole(agentRole: string): string {
272+
if (!agentRole) return 'pai';
273+
274+
// Remove "subagents/" prefix if present (case-insensitive)
275+
let role = agentRole.replace(/^subagents\//i, '').toLowerCase();
276+
277+
// Role keywords to look for anywhere in the string
278+
// Order matters: more specific patterns first
279+
const roleKeywords: [RegExp, string][] = [
280+
// Researcher patterns (prefix or contains)
281+
[/researcher/i, 'researcher'],
282+
[/research/i, 'researcher'],
283+
284+
// Architect patterns
285+
[/architect/i, 'architect'],
286+
287+
// Engineer patterns (includes "dev" for sparc-dev)
288+
[/engineer/i, 'engineer'],
289+
[/\bdev\b/i, 'engineer'], // "sparc-dev" → engineer
290+
291+
// Designer patterns
292+
[/designer/i, 'designer'],
293+
294+
// Security patterns
295+
[/pentester/i, 'pentester'],
296+
297+
// These map to researcher
298+
[/analyst/i, 'researcher'],
299+
[/explorer/i, 'researcher'],
300+
[/^explore$/i, 'researcher'],
301+
[/^intern$/i, 'researcher'],
302+
];
303+
304+
for (const [pattern, normalized] of roleKeywords) {
305+
if (pattern.test(role)) {
306+
return normalized;
307+
}
308+
}
309+
310+
// Return lowercase role if no pattern matched
311+
return role;
312+
}
313+
263314
private determineArtifactType(agentRole: string, isLearning: boolean, sections: Record<string, string>): string {
264315
const summary = (sections['SUMMARY'] || '').toLowerCase();
265-
if (agentRole === 'architect') return 'DECISION';
266-
if (agentRole === 'researcher' || agentRole === 'pentester') return 'RESEARCH';
267-
if (agentRole === 'engineer' || agentRole === 'designer') {
316+
const normalizedRole = this.normalizeAgentRole(agentRole);
317+
318+
if (normalizedRole === 'architect') return 'DECISION';
319+
if (normalizedRole === 'researcher' || normalizedRole === 'pentester') return 'RESEARCH';
320+
if (normalizedRole === 'engineer' || normalizedRole === 'designer') {
268321
if (summary.includes('fix') || summary.includes('bug') || summary.includes('issue')) return 'BUG';
269322
if (summary.includes('refactor') || summary.includes('improve') || summary.includes('cleanup')) return 'REFACTOR';
270323
return 'FEATURE';

0 commit comments

Comments
 (0)