agents/sessionLabeler.ts
Rama main · réplica de lectura del repositorio original.
/**
* Track per-session labels so log lines from parallel subagents can be
* differentiated. The orchestrator dispatches lens subagents (e.g. reviewfrog)
* via the Task tool; each subagent runs in its own opencode/claude Session
* with its own `sessionID` (or `session_id`) tag on the NDJSON event stream.
*
* Without per-session prefixing, parallel subagent tool_use / tool_result /
* text events appear as a single interleaved stream tagged with `[Pullfrog]`,
* making it impossible for a human reading the logs to attribute work to a
* specific lens.
*
* The labeler is deliberately runtime-agnostic — both opencode.ts and
* claude.ts feed it the same shape. The contract is FIFO: when the orchestrator
* dispatches N task tool_use blocks in a single assistant turn (the parallel
* fan-out the multi-lens prompt requires), the i-th new sessionID is assumed
* to belong to the i-th task dispatch. This is correct as long as parallel
* dispatches are emitted in source-order and the runtimes respect that order
* when assigning child sessions; we do not depend on it for correctness of
* the read-only contract — only for log readability.
*/
export interface TaskDispatchInput {
description?: string | undefined;
subagent_type?: string | undefined;
prompt?: string | undefined;
}
export const ORCHESTRATOR_LABEL = "orchestrator";
const LENS_PROMPT_PATTERN = /^\s*(?:lens|Lens|LENS)\s*[:=]\s*([A-Za-z][\w &/.-]{0,60})/m;
function slug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^\w-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/**
* Extract a human-readable label from a Task tool's input. Tries (in order):
* 1. explicit `lens: <name>` marker on a line in the prompt — preferred,
* lets the orchestrator name the lens deterministically
* 2. the Task tool's `description` field — short, written by orchestrator
* per call, usually enough
* 3. the `subagent_type` (e.g. `reviewfrog`) — falls back to the named
* subagent identity when description is missing
* 4. generic "subagent" — last resort
*/
export function deriveLabelFromTaskInput(input: TaskDispatchInput): string {
if (typeof input.prompt === "string") {
const match = input.prompt.match(LENS_PROMPT_PATTERN);
if (match?.[1]) {
const slugged = slug(match[1]);
if (slugged) return `lens:${slugged}`;
}
}
if (input.description) {
const slugged = slug(input.description);
if (slugged) return `lens:${slugged}`;
}
if (input.subagent_type) {
return input.subagent_type;
}
return "subagent";
}
/**
* Stateful tracker mapping subagent activity back to human-readable labels.
*
* Two attribution channels are supported because the runtimes differ:
*
* - **OpenCode** spawns each subagent as its own opencode `Session` with
* a distinct `sessionID`. The harness records each Task dispatch into a
* pending FIFO queue; the next previously-unseen sessionID consumes the
* head of the queue and binds it to that label.
*
* - **Claude Code** runs subagents inside the orchestrator's session — they
* all share `session_id` — and instead stamps every subagent message with
* `parent_tool_use_id` pointing at the Agent tool_use id that spawned them.
* The harness binds each Agent tool_use id to its dispatched label up
* front, then `labelFor` looks the label up directly when an event arrives
* carrying that `parent_tool_use_id`.
*
* `labelFor(sessionID, parentToolUseId?)` accepts both: when
* `parentToolUseId` is set and known it short-circuits to the direct mapping;
* otherwise it falls through to the FIFO/sessionID path.
*/
export class SessionLabeler {
private readonly labels = new Map<string, string>();
private readonly labelsByToolUseId = new Map<string, string>();
private readonly pendingLabels: string[] = [];
private fallbackCounter = 0;
/**
* Record a Task/Agent tool dispatch.
*
* @param input Task tool input — used to derive the lens label.
* @param toolUseId Optional Agent tool_use id. When provided, future events
* carrying `parent_tool_use_id === toolUseId` resolve
* directly to this label without consuming the FIFO queue
* (Claude path). Always also pushed to the FIFO queue so
* the OpenCode path still works when toolUseId is absent.
*/
recordTaskDispatch(input: TaskDispatchInput, toolUseId?: string | null): string {
const label = deriveLabelFromTaskInput(input);
this.pendingLabels.push(label);
if (toolUseId) this.labelsByToolUseId.set(toolUseId, label);
return label;
}
/**
* Return a label for the given event.
*
* @param sessionID Session id from the event (OpenCode: per-session;
* Claude: shared across orchestrator + subagents).
* @param parentToolUseId Claude's `parent_tool_use_id` — non-null on
* subagent messages. When set and known, takes
* priority over the FIFO/sessionID path.
*/
labelFor(sessionID: string | undefined | null, parentToolUseId?: string | null): string {
// Claude path: subagent messages carry parent_tool_use_id pointing at
// the Agent tool_use that spawned them. resolve directly without
// touching the sessionID-keyed map (which is bound to the orchestrator
// for the shared session_id and would otherwise misattribute).
if (parentToolUseId) {
const direct = this.labelsByToolUseId.get(parentToolUseId);
if (direct) return direct;
}
if (!sessionID) return ORCHESTRATOR_LABEL;
const existing = this.labels.get(sessionID);
if (existing) return existing;
let label: string;
if (this.labels.size === 0) {
label = ORCHESTRATOR_LABEL;
} else if (this.pendingLabels.length > 0) {
label = this.pendingLabels.shift() as string;
} else {
this.fallbackCounter += 1;
label = `subagent#${this.fallbackCounter}`;
}
this.labels.set(sessionID, label);
return label;
}
/** number of distinct sessions seen so far (for diagnostics) */
size(): number {
return this.labels.size;
}
/** all (sessionID, label) pairs, oldest first */
entries(): Array<[string, string]> {
return Array.from(this.labels.entries());
}
/** how many pending labels are queued waiting to bind to a new session */
pendingDispatchCount(): number {
return this.pendingLabels.length;
}
}
/**
* Format a log message with a session label prefix in magenta. Mirrors the
* style of utils/log.ts:prefixLines() so per-session prefixes look the same
* as the dormant withLogPrefix-based ones.
*/
export function formatWithLabel(label: string, message: string): string {
const MAGENTA = "\x1b[35m";
const RESET = "\x1b[0m";
const colored = `${MAGENTA}[${label}]${RESET} `;
return message
.split("\n")
.map((line) => `${colored}${line}`)
.join("\n");
}