Browse Source

fix: anchor task context summaries

Alvin Unreal 3 months ago
parent
commit
f73388cee1

+ 7 - 7
docs/session-management.md

@@ -47,13 +47,13 @@ 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.
+Child agents are asked to return a short `<context_summary>` metadata block as
+the final standalone block in 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 is capped
+to keep prompt usage bounded. 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.
 
 ---
 

+ 4 - 6
src/hooks/task-session-manager/index.test.ts

@@ -80,9 +80,9 @@ describe('task-session-manager hook', () => {
     );
 
     expect(beforeOutput.args.prompt).toContain('<context_summary>');
-    expect(beforeOutput.args.prompt).toContain('short paragraph');
+    expect(beforeOutput.args.prompt).toContain('under 280 characters');
     expect(beforeOutput.args.prompt).toContain(
-      'final child inside your <results> block',
+      'outside any other XML/result tags',
     );
     expect(beforeOutput.args.prompt).toContain(
       'Do not omit the closing </context_summary> tag',
@@ -139,11 +139,9 @@ describe('task-session-manager hook', () => {
       beforeOutput,
     );
 
+    expect(beforeOutput.args.prompt).toContain('After your final answer');
     expect(beforeOutput.args.prompt).toContain(
-      'At the end of your final answer',
-    );
-    expect(beforeOutput.args.prompt).toContain(
-      '<context_summary>List the specific files',
+      '<context_summary>Briefly list the specific reusable context',
     );
   });
 

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

@@ -43,8 +43,8 @@ const CONTEXT_SUMMARY_INSTRUCTION_MARKER =
 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>',
+  'After your final answer, append exactly one standalone metadata block for future session reuse. It must be the very last thing in your response, outside any other XML/result tags. Keep it concise: 1-2 sentences, under 280 characters, focused on concrete files, decisions, findings, and state this child session can recall if resumed. Do not omit the closing </context_summary> tag:',
+  '<context_summary>Briefly list the specific reusable context this child session can recall if resumed.</context_summary>',
 ].join('\n');
 
 function isAgentName(value: unknown): value is AgentName {

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

@@ -61,6 +61,23 @@ describe('SessionManager', () => {
       'ora-1 architecture — Reviewed session lifecycle and cleanup behavior.',
     );
   });
+
+  test('normalizes and truncates session summaries', () => {
+    const manager = new SessionManager(2);
+
+    manager.remember({
+      parentSessionId: 'parent-1',
+      taskId: 'task-1',
+      agentType: 'oracle',
+      label: 'architecture',
+      contextSummary: ` ${'a'.repeat(320)} `,
+    });
+
+    const prompt = manager.formatForPrompt('parent-1');
+
+    expect(prompt).toContain(`ora-1 architecture — ${'a'.repeat(280)}`);
+    expect(prompt).not.toContain('a'.repeat(281));
+  });
 });
 
 describe('deriveTaskSessionLabel', () => {

+ 13 - 3
src/utils/session-manager.ts

@@ -12,6 +12,15 @@ export interface RememberedTaskSession {
 
 type SessionGroupMap = Map<AgentName, RememberedTaskSession[]>;
 
+const MAX_CONTEXT_SUMMARY_LENGTH = 280;
+
+function normalizeContextSummary(value?: string): string | undefined {
+  const normalized = normalizeWhitespace(value ?? '');
+  if (!normalized) return undefined;
+
+  return normalized.slice(0, MAX_CONTEXT_SUMMARY_LENGTH);
+}
+
 function aliasPrefix(agentType: AgentName): string {
   switch (agentType) {
     case 'explorer':
@@ -82,6 +91,7 @@ export class SessionManager {
     contextSummary?: string;
   }): RememberedTaskSession {
     const now = this.nextOrder();
+    const contextSummary = normalizeContextSummary(input.contextSummary);
     const group = this.getAgentGroup(
       input.parentSessionId,
       input.agentType,
@@ -94,8 +104,8 @@ export class SessionManager {
 
     if (existing) {
       existing.label = input.label;
-      if (input.contextSummary) {
-        existing.contextSummary = input.contextSummary;
+      if (contextSummary) {
+        existing.contextSummary = contextSummary;
       }
       existing.lastUsedAt = this.nextOrder();
       return existing;
@@ -106,7 +116,7 @@ export class SessionManager {
       taskId: input.taskId,
       agentType: input.agentType,
       label: input.label,
-      contextSummary: input.contextSummary,
+      contextSummary,
       createdAt: now,
       lastUsedAt: now,
     };

+ 53 - 5
src/utils/task.test.ts

@@ -28,7 +28,7 @@ describe('parseTaskIdFromTaskOutput', () => {
 });
 
 describe('parseContextSummaryFromTaskOutput', () => {
-  test('parses and normalizes the last context summary block', () => {
+  test('parses and normalizes the final context summary block', () => {
     const output = [
       '<context_summary>old summary</context_summary>',
       '<task_result>',
@@ -44,6 +44,31 @@ describe('parseContextSummaryFromTaskOutput', () => {
     );
   });
 
+  test('ignores context summary mentions that are not final metadata', () => {
+    const output = [
+      '<task_result>',
+      '<results>',
+      '<answer>Discusses <context_summary> syntax only.</answer>',
+      '</results>',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseContextSummaryFromTaskOutput(output)).toBeUndefined();
+  });
+
+  test('ignores context summary blocks nested inside task results', () => {
+    const output = [
+      '<task_result>',
+      '<results>',
+      '<answer>done</answer>',
+      '<context_summary>Nested summary should not parse.</context_summary>',
+      '</results>',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseContextSummaryFromTaskOutput(output)).toBeUndefined();
+  });
+
   test('returns undefined when context block is absent or empty', () => {
     expect(parseContextSummaryFromTaskOutput('plain output')).toBeUndefined();
     expect(
@@ -53,19 +78,29 @@ describe('parseContextSummaryFromTaskOutput', () => {
     ).toBeUndefined();
   });
 
-  test('parses malformed trailing context summary blocks', () => {
+  test('parses malformed final context summary blocks', () => {
     const output = [
       '<task_result>',
       '<results>',
       '<answer>done</answer>',
-      '<context_summary>Remember inspected session code.',
       '</task_result>',
+      '<context_summary>Remember inspected session code.',
     ].join('\n');
 
     expect(parseContextSummaryFromTaskOutput(output)).toBe(
       'Remember inspected session code.',
     );
   });
+
+  test('truncates long context summaries', () => {
+    const longSummary = 'a'.repeat(320);
+
+    expect(
+      parseContextSummaryFromTaskOutput(
+        `<context_summary>${longSummary}</context_summary>`,
+      ),
+    ).toHaveLength(280);
+  });
 });
 
 describe('stripContextSummaryFromTaskOutput', () => {
@@ -90,14 +125,26 @@ describe('stripContextSummaryFromTaskOutput', () => {
     );
   });
 
-  test('removes malformed trailing context summary metadata blocks', () => {
+  test('does not remove context summary mentions inside task results', () => {
+    const output = [
+      '<task_result>',
+      '<results>',
+      '<answer>Discusses <context_summary> syntax only.</answer>',
+      '</results>',
+      '</task_result>',
+    ].join('\n');
+
+    expect(stripContextSummaryFromTaskOutput(output)).toBe(output);
+  });
+
+  test('removes malformed final context summary metadata blocks', () => {
     const output = [
       'task_id: session-abc-123',
       '<task_result>',
       '<results>',
       '<answer>done</answer>',
-      '<context_summary>metadata only',
       '</task_result>',
+      '<context_summary>metadata only',
     ].join('\n');
 
     expect(stripContextSummaryFromTaskOutput(output)).toBe(
@@ -106,6 +153,7 @@ describe('stripContextSummaryFromTaskOutput', () => {
         '<task_result>',
         '<results>',
         '<answer>done</answer>',
+        '</task_result>',
       ].join('\n'),
     );
   });

+ 17 - 18
src/utils/task.ts

@@ -19,7 +19,13 @@ export function parseTaskIdFromTaskOutput(output: string): string | undefined {
   return undefined;
 }
 
-const MAX_CONTEXT_SUMMARY_LENGTH = 600;
+const MAX_CONTEXT_SUMMARY_LENGTH = 280;
+
+const FINAL_CONTEXT_SUMMARY_PATTERN =
+  /(?:^|\n)\s*<context_summary>((?:(?!<context_summary>)[\s\S])*?)<\/context_summary>\s*$/i;
+
+const FINAL_MALFORMED_CONTEXT_SUMMARY_PATTERN =
+  /(?:^|\n)\s*<context_summary>((?:(?!<context_summary>|<\/context_summary>)[\s\S])*?)\s*$/i;
 
 function normalizeContextSummary(value: string): string | undefined {
   const normalized = value.replace(/\s+/g, ' ').trim();
@@ -29,34 +35,27 @@ function normalizeContextSummary(value: string): string | undefined {
 }
 
 /**
- * Parse the last context summary metadata block from Task tool output.
+ * Parse the final standalone context summary metadata block from Task 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;
-  }
+  const match = FINAL_CONTEXT_SUMMARY_PATTERN.exec(output);
+  if (match) return normalizeContextSummary(match[1]);
+
+  const fallbackMatch = FINAL_MALFORMED_CONTEXT_SUMMARY_PATTERN.exec(output);
 
-  return normalizeContextSummary(lastMatch[1]);
+  return fallbackMatch ? normalizeContextSummary(fallbackMatch[1]) : undefined;
 }
 
 /**
- * Remove context summary metadata blocks before the parent model sees output.
+ * Remove the final standalone context summary metadata block 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(FINAL_CONTEXT_SUMMARY_PATTERN, '')
+    .replace(FINAL_MALFORMED_CONTEXT_SUMMARY_PATTERN, '')
     .replace(/\n{3,}/g, '\n\n')
     .trimEnd();
 }