Преглед изворни кода

feat: remember task context summaries

Alvin Unreal пре 3 месеци
родитељ
комит
84b96e13f4

+ 10 - 2
docs/session-management.md

@@ -39,14 +39,22 @@ The orchestrator sees a compact reminder in its system context, for example:
 
 ```text
 ### Resumable Sessions
-explorer: exp-1 Search routing files
-oracle: ora-1 Review auth architecture
+- explorer: exp-1 Search routing files — Found route handlers and middleware entry points.
+- oracle: ora-1 Review auth architecture — Reviewed auth flow and token refresh trade-offs.
 ```
 
 On a related follow-up, the orchestrator can reuse that session instead of
 launching a fresh one. If the remembered child session no longer exists, the
 plugin drops the stale entry and falls back to a new session automatically.
 
+Child agents are asked to return a short `<context_summary>` metadata block at
+the end of delegated task results. The context summary should capture the
+concrete context now present in that child session, such as files inspected,
+findings, decisions, or state it can recall if resumed. It can be a short
+paragraph when one sentence would lose useful context. The plugin stores that
+summary when present, strips the metadata from the visible result, and falls
+back to the original task label if the agent omits it.
+
 ---
 
 ## Scope and Safety

+ 1 - 0
src/agents/orchestrator.ts

@@ -187,6 +187,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential.
 
 ### Session Reuse
 - Reuse an available specialist session only for clear follow-up work on the same thread.
+- Prefer sessions whose summaries match the intended follow-up context.
 - Prefer a fresh session for unrelated work, even with the same specialist.
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - If reuse is unclear, start a fresh session.

+ 87 - 0
src/hooks/task-session-manager/index.test.ts

@@ -60,6 +60,93 @@ describe('task-session-manager hook', () => {
     expect(system.system.join('\n')).toContain('explorer: exp-1 config schema');
   });
 
+  test('appends context instructions and stores returned summaries', async () => {
+    const { hook } = createHook();
+    const beforeOutput = {
+      args: {
+        subagent_type: 'oracle',
+        description: 'session lifecycle review',
+        prompt: 'review session lifecycle',
+      },
+    };
+
+    await hook['tool.execute.before'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      beforeOutput,
+    );
+
+    expect(beforeOutput.args.prompt).toContain('<context_summary>');
+    expect(beforeOutput.args.prompt).toContain('short paragraph');
+    expect(beforeOutput.args.prompt).toContain(
+      'final child inside your <results> block',
+    );
+    expect(beforeOutput.args.prompt).toContain(
+      'Do not omit the closing </context_summary> tag',
+    );
+
+    const afterOutput = {
+      output: [
+        'task_id: child-1 (for resuming to continue this task if needed)',
+        '<task_result>',
+        'Reviewed cleanup behavior.',
+        '</task_result>',
+        '<context_summary>Contains index.ts hook wiring, task.ts parser behavior, and session-manager rendering details.</context_summary>',
+      ].join('\n'),
+    };
+
+    await hook['tool.execute.after'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      afterOutput,
+    );
+
+    expect(afterOutput.output).not.toContain('<context_summary>');
+
+    const system = { system: ['base'] };
+    await hook['experimental.chat.system.transform'](
+      { sessionID: 'parent-1' },
+      system,
+    );
+
+    expect(system.system.join('\n')).toContain(
+      'oracle: ora-1 session lifecycle review — Contains index.ts hook wiring, task.ts parser behavior, and session-manager rendering details.',
+    );
+  });
+
+  test('still appends instructions when prompt mentions context summary tags', async () => {
+    const { hook } = createHook();
+    const beforeOutput = {
+      args: {
+        subagent_type: 'explorer',
+        description: 'inspect parser',
+        prompt: 'Find code that parses <context_summary> blocks.',
+      },
+    };
+
+    await hook['tool.execute.before'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      beforeOutput,
+    );
+
+    expect(beforeOutput.args.prompt).toContain(
+      'At the end of your final answer',
+    );
+    expect(beforeOutput.args.prompt).toContain(
+      '<context_summary>List the specific files',
+    );
+  });
+
   test('resolves remembered aliases to real task ids before execution', async () => {
     const { hook } = createHook();
 

+ 32 - 2
src/hooks/task-session-manager/index.ts

@@ -2,8 +2,10 @@ import type { PluginInput } from '@opencode-ai/plugin';
 import type { AgentName } from '../../config';
 import {
   deriveTaskSessionLabel,
+  parseContextSummaryFromTaskOutput,
   parseTaskIdFromTaskOutput,
   SessionManager,
+  stripContextSummaryFromTaskOutput,
 } from '../../utils';
 
 interface TaskArgs {
@@ -35,6 +37,16 @@ const AGENT_NAME_SET = new Set<AgentName>([
 
 const MAX_PENDING_TASK_CALLS = 100;
 
+const CONTEXT_SUMMARY_INSTRUCTION_MARKER =
+  '<!-- oh-my-opencode-slim-context-summary-instruction -->';
+
+const CONTEXT_SUMMARY_INSTRUCTION = [
+  '',
+  CONTEXT_SUMMARY_INSTRUCTION_MARKER,
+  'At the end of your final answer, include a brief metadata block for future session reuse. Include it as the final child inside your <results> block when you return structured results. Focus on the concrete context/knowledge now present in this child session, not a generic description of the task. A short paragraph is fine if needed. Do not omit the closing </context_summary> tag:',
+  '<context_summary>List the specific files, decisions, findings, and state this child session can recall if resumed.</context_summary>',
+].join('\n');
+
 function isAgentName(value: unknown): value is AgentName {
   return typeof value === 'string' && AGENT_NAME_SET.has(value as AgentName);
 }
@@ -43,6 +55,14 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null;
 }
 
+function appendContextSummaryInstruction(prompt: string): string {
+  if (prompt.includes(CONTEXT_SUMMARY_INSTRUCTION_MARKER)) {
+    return prompt;
+  }
+
+  return `${prompt.trimEnd()}${CONTEXT_SUMMARY_INSTRUCTION}`;
+}
+
 export function createTaskSessionManagerHook(
   _ctx: PluginInput,
   options: {
@@ -115,6 +135,10 @@ export function createTaskSessionManagerHook(
         agentType: args.subagent_type,
       });
 
+      if (typeof args.prompt === 'string') {
+        args.prompt = appendContextSummaryInstruction(args.prompt);
+      }
+
       if (input.callID) {
         rememberPendingCall({
           callId: input.callID,
@@ -166,11 +190,16 @@ export function createTaskSessionManagerHook(
       const pending = takePendingCall(input.callID);
 
       if (!pending || typeof output.output !== 'string') return;
-      const taskId = parseTaskIdFromTaskOutput(output.output);
+
+      const rawOutput = output.output;
+      const contextSummary = parseContextSummaryFromTaskOutput(rawOutput);
+      const strippedOutput = stripContextSummaryFromTaskOutput(rawOutput);
+      output.output = strippedOutput;
+      const taskId = parseTaskIdFromTaskOutput(strippedOutput);
       if (!taskId) {
         if (
           pending.resumedTaskId &&
-          isMissingRememberedSessionError(output.output)
+          isMissingRememberedSessionError(strippedOutput)
         ) {
           sessionManager.drop(
             pending.parentSessionId,
@@ -194,6 +223,7 @@ export function createTaskSessionManagerHook(
         taskId,
         agentType: pending.agentType,
         label: pending.label,
+        contextSummary,
       });
     },
 

+ 16 - 0
src/utils/session-manager.test.ts

@@ -45,6 +45,22 @@ describe('SessionManager', () => {
 
     expect(manager.formatForPrompt('parent-1')).toBeUndefined();
   });
+
+  test('renders session summaries when available', () => {
+    const manager = new SessionManager(2);
+
+    manager.remember({
+      parentSessionId: 'parent-1',
+      taskId: 'task-1',
+      agentType: 'oracle',
+      label: 'architecture',
+      contextSummary: 'Reviewed session lifecycle and cleanup behavior.',
+    });
+
+    expect(manager.formatForPrompt('parent-1')).toContain(
+      'ora-1 architecture — Reviewed session lifecycle and cleanup behavior.',
+    );
+  });
 });
 
 describe('deriveTaskSessionLabel', () => {

+ 12 - 1
src/utils/session-manager.ts

@@ -5,6 +5,7 @@ export interface RememberedTaskSession {
   taskId: string;
   agentType: AgentName;
   label: string;
+  contextSummary?: string;
   createdAt: number;
   lastUsedAt: number;
 }
@@ -78,6 +79,7 @@ export class SessionManager {
     taskId: string;
     agentType: AgentName;
     label: string;
+    contextSummary?: string;
   }): RememberedTaskSession {
     const now = this.nextOrder();
     const group = this.getAgentGroup(
@@ -92,6 +94,9 @@ export class SessionManager {
 
     if (existing) {
       existing.label = input.label;
+      if (input.contextSummary) {
+        existing.contextSummary = input.contextSummary;
+      }
       existing.lastUsedAt = this.nextOrder();
       return existing;
     }
@@ -101,6 +106,7 @@ export class SessionManager {
       taskId: input.taskId,
       agentType: input.agentType,
       label: input.label,
+      contextSummary: input.contextSummary,
       createdAt: now,
       lastUsedAt: now,
     };
@@ -167,7 +173,12 @@ export class SessionManager {
       .map(
         ([agentType, entries]) =>
           `- ${agentType}: ${entries
-            .map((entry) => `${entry.alias} ${entry.label}`)
+            .map((entry) => {
+              const base = `${entry.alias} ${entry.label}`;
+              return entry.contextSummary
+                ? `${base} — ${entry.contextSummary}`
+                : base;
+            })
             .join('; ')}`,
       );
 

+ 89 - 1
src/utils/task.test.ts

@@ -1,5 +1,9 @@
 import { describe, expect, test } from 'bun:test';
-import { parseTaskIdFromTaskOutput } from './task';
+import {
+  parseContextSummaryFromTaskOutput,
+  parseTaskIdFromTaskOutput,
+  stripContextSummaryFromTaskOutput,
+} from './task';
 
 describe('parseTaskIdFromTaskOutput', () => {
   test('parses task_id line from successful task tool output', () => {
@@ -22,3 +26,87 @@ describe('parseTaskIdFromTaskOutput', () => {
     expect(parseTaskIdFromTaskOutput(output)).toBeUndefined();
   });
 });
+
+describe('parseContextSummaryFromTaskOutput', () => {
+  test('parses and normalizes the last context summary block', () => {
+    const output = [
+      '<context_summary>old summary</context_summary>',
+      '<task_result>',
+      'done',
+      '</task_result>',
+      '<context_summary>',
+      '  Reviewed   session management\n and found implementation points. ',
+      '</context_summary>',
+    ].join('\n');
+
+    expect(parseContextSummaryFromTaskOutput(output)).toBe(
+      'Reviewed session management and found implementation points.',
+    );
+  });
+
+  test('returns undefined when context block is absent or empty', () => {
+    expect(parseContextSummaryFromTaskOutput('plain output')).toBeUndefined();
+    expect(
+      parseContextSummaryFromTaskOutput(
+        '<context_summary>   \n </context_summary>',
+      ),
+    ).toBeUndefined();
+  });
+
+  test('parses malformed trailing context summary blocks', () => {
+    const output = [
+      '<task_result>',
+      '<results>',
+      '<answer>done</answer>',
+      '<context_summary>Remember inspected session code.',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseContextSummaryFromTaskOutput(output)).toBe(
+      'Remember inspected session code.',
+    );
+  });
+});
+
+describe('stripContextSummaryFromTaskOutput', () => {
+  test('removes context summary metadata blocks', () => {
+    const output = [
+      'task_id: session-abc-123',
+      '',
+      '<task_result>',
+      'done',
+      '</task_result>',
+      '<context_summary>metadata only</context_summary>',
+    ].join('\n');
+
+    expect(stripContextSummaryFromTaskOutput(output)).toBe(
+      [
+        'task_id: session-abc-123',
+        '',
+        '<task_result>',
+        'done',
+        '</task_result>',
+      ].join('\n'),
+    );
+  });
+
+  test('removes malformed trailing context summary metadata blocks', () => {
+    const output = [
+      'task_id: session-abc-123',
+      '<task_result>',
+      '<results>',
+      '<answer>done</answer>',
+      '<context_summary>metadata only',
+      '</task_result>',
+    ].join('\n');
+
+    expect(stripContextSummaryFromTaskOutput(output)).toBe(
+      [
+        'task_id: session-abc-123',
+        '<task_result>',
+        '<results>',
+        '<answer>done</answer>',
+      ].join('\n'),
+    );
+  });
+});

+ 42 - 0
src/utils/task.ts

@@ -18,3 +18,45 @@ export function parseTaskIdFromTaskOutput(output: string): string | undefined {
 
   return undefined;
 }
+
+const MAX_CONTEXT_SUMMARY_LENGTH = 600;
+
+function normalizeContextSummary(value: string): string | undefined {
+  const normalized = value.replace(/\s+/g, ' ').trim();
+  if (!normalized) return undefined;
+
+  return normalized.slice(0, MAX_CONTEXT_SUMMARY_LENGTH);
+}
+
+/**
+ * Parse the last context summary metadata block from Task tool output.
+ */
+export function parseContextSummaryFromTaskOutput(
+  output: string,
+): string | undefined {
+  const matches = [
+    ...output.matchAll(/<context_summary>([\s\S]*?)<\/context_summary>/gi),
+  ];
+  const lastMatch = matches.at(-1);
+  if (!lastMatch) {
+    const fallbackMatch =
+      /<context_summary>([\s\S]*?)(?:<\/task_result>|$)/i.exec(output);
+
+    return fallbackMatch
+      ? normalizeContextSummary(fallbackMatch[1])
+      : undefined;
+  }
+
+  return normalizeContextSummary(lastMatch[1]);
+}
+
+/**
+ * Remove context summary metadata blocks before the parent model sees output.
+ */
+export function stripContextSummaryFromTaskOutput(output: string): string {
+  return output
+    .replace(/\n?\s*<context_summary>[\s\S]*?<\/context_summary>\s*/gi, '\n')
+    .replace(/\n?\s*<context_summary>[\s\S]*?(?:<\/task_result>|$)/i, '\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trimEnd();
+}