Reference Implementations

Research

document-summarizer

beginner

Summarize long documents with intelligent chunking (map-reduce pattern).

APIs Used

ctx.filesctx.llmctx.promptsctx.telemetry.emit

Capabilities Required

document/summarize

What this demonstrates

  • 1ctx.files.readText() to load large documents
  • 2ctx.prompts.load() for reusable summarization prompt templates
  • 3ctx.llm.complete() with map-reduce chunking for long-context documents
  • 4Map-reduce pattern: chunk → summarize each → synthesize final summary
  • 5ctx.telemetry.emit via emitReferenceAuthorSignal on prompt-registry fallback and multi-chunk runs
typescript
/**
* Document Summarizer - Production Reference Agent
*
* Canon alignment: KB 105
* Demonstrates: ctx.files, ctx.llm, ctx.prompts, chunking strategy for long documents
*
* Real use case: Summarize long documents with intelligent chunking.
* For documents exceeding LLM context, split into chunks, summarize each,
* then synthesize a final summary. This is the pattern developers should copy.
*
* Updated for prompt management:
* - Uses ctx.prompts.load() for governed summarization prompts (ECC-08)
* - Falls back to inline system strings when prompts are not in registry (telemetry)
* - Threads promptMetadata for telemetry when using managed prompts
*/
import { handler, withProvenanceContext } from '@human/agent-sdk';
import type { ExecutionContext } from '@human/agent-sdk';
import { emitReferenceAuthorSignal } from '../../lib/reference-author-telemetry.js';
export const AGENT_ID = 'document-summarizer';
export const VERSION = '1.0.0';
export const CAPABILITIES = ['document/summarize'];
export interface DocumentSummarizerInput {
document_path: string;
max_length?: number;
}
export interface DocumentSummarizerOutput {
success: boolean;
summary: string;
word_count: number;
chunks_processed: number;
provenance_id: string;
}
/** Approximate token limit for a single LLM call context window */
const CHUNK_CHAR_LIMIT = 8000;
/**
* Split text into chunks at paragraph boundaries.
* Avoids splitting mid-sentence for better summarization.
*/
function chunkText(text: string, maxChars: number): string[] {
if (text.length <= maxChars) return [text];
const paragraphs = text.split(/\n\n+/);
const chunks: string[] = [];
let current = '';
for (const para of paragraphs) {
if (current.length + para.length + 2 > maxChars && current.length > 0) {
chunks.push(current.trim());
current = para;
} else {
current += (current ? '\n\n' : '') + para;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
const execute = async (
ctx: ExecutionContext,
input: DocumentSummarizerInput
): Promise<DocumentSummarizerOutput> => {
ctx.log.info('Summarizing document', { path: input.document_path });
const content = await ctx.files.readText(input.document_path);
const maxLength = input.max_length ?? 500;
const chunks = chunkText(content, CHUNK_CHAR_LIMIT);
ctx.log.info('Document chunked', { totalChars: content.length, chunks: chunks.length });
let finalSummary = '';
const useGovernedPrompts = async (): Promise<boolean> => {
try {
const singleChunkPrompt = await ctx.prompts.load('core/agents/document-summarizer/system');
const chunkSectionPrompt = await ctx.prompts.load(
'core/agents/document-summarizer/chunk-section'
);
const chunkSynthesisPrompt = await ctx.prompts.load(
'core/agents/document-summarizer/chunk-synthesis'
);
if (chunks.length > 1) {
await emitReferenceAuthorSignal(ctx, 'summarizer_map_reduce', {
chunk_count: chunks.length,
document_path: input.document_path,
});
}
if (chunks.length === 1) {
const result = await ctx.llm.complete({
system: singleChunkPrompt.render({ max_length: String(maxLength) }),
prompt: chunks[0]!,
temperature: 0.3,
maxTokens: maxLength * 2,
promptMetadata: singleChunkPrompt.toCallMetadata(),
});
finalSummary = result.content;
} else {
const chunkSummaries: string[] = [];
for (let i = 0; i < chunks.length; i++) {
const result = await ctx.llm.complete({
system: chunkSectionPrompt.render({
part: String(i + 1),
total: String(chunks.length),
}),
prompt: chunks[i]!,
temperature: 0.3,
maxTokens: 300,
promptMetadata: chunkSectionPrompt.toCallMetadata(),
});
chunkSummaries.push(result.content);
}
const synthesisResult = await ctx.llm.complete({
system: chunkSynthesisPrompt.render({ max_length: String(maxLength) }),
prompt: chunkSummaries.map((s, i) => `Section ${i + 1}: ${s}`).join('\n\n'),
temperature: 0.3,
maxTokens: maxLength * 2,
promptMetadata: chunkSynthesisPrompt.toCallMetadata(),
});
finalSummary = synthesisResult.content;
}
return true;
} catch {
await emitReferenceAuthorSignal(ctx, 'summarizer_prompt_registry_fallback', {
prompt_uri: 'core/agents/document-summarizer/*',
});
ctx.log.debug('Governed summarizer prompts not available, using inline fallback');
return false;
}
};
const governedOk = await useGovernedPrompts();
if (!governedOk) {
if (chunks.length > 1) {
await emitReferenceAuthorSignal(ctx, 'summarizer_map_reduce', {
chunk_count: chunks.length,
document_path: input.document_path,
});
}
if (chunks.length === 1) {
const result = await ctx.llm.complete({
system: `Summarize the document in under ${maxLength} words. Preserve key facts and structure.`,
prompt: chunks[0]!,
temperature: 0.3,
maxTokens: maxLength * 2,
});
finalSummary = result.content;
} else {
const chunkSummaries: string[] = [];
for (let i = 0; i < chunks.length; i++) {
const result = await ctx.llm.complete({
system: `Summarize this section (part ${i + 1} of ${chunks.length}) in 2-3 sentences. Focus on key points.`,
prompt: chunks[i]!,
temperature: 0.3,
maxTokens: 300,
});
chunkSummaries.push(result.content);
}
const synthesisResult = await ctx.llm.complete({
system: `Synthesize these section summaries into one coherent summary of under ${maxLength} words. Maintain logical flow and highlight the most important points.`,
prompt: chunkSummaries.map((s, i) => `Section ${i + 1}: ${s}`).join('\n\n'),
temperature: 0.3,
maxTokens: maxLength * 2,
});
finalSummary = synthesisResult.content;
}
}
const wordCount = finalSummary.split(/\s+/).length;
const provenanceId = await ctx.provenance.log(
withProvenanceContext(ctx, {
action: 'document:summarized',
status: 'success',
input: { document_path: input.document_path },
output: { word_count: wordCount, chunks_processed: chunks.length },
})
);
return {
success: true,
summary: finalSummary,
word_count: wordCount,
chunks_processed: chunks.length,
provenance_id: provenanceId,
};
};
export default handler({
name: AGENT_ID,
id: AGENT_ID,
version: VERSION,
capabilities: CAPABILITIES,
manifest: {
operations: [
{
name: 'summarize',
description: 'Summarize long documents with intelligent chunking and optional managed prompts',
paramsSchema: {
document_path: { type: 'string', required: true, description: 'Path to document' },
max_length: { type: 'number', description: 'Max summary length in words' },
},
resultKind: 'agent.document-summarizer.result',
},
],
},
execute,
});

Run the tests

From monorepo root

$ pnpm test:agents:reference

$ pnpm test:agents:reference:verbose

The reference suite runs all 23 agents with createMockExecutionContext(), verifying every ctx.* API call and output shape.

See Also

SDK Reference