The Atlas Lavern's documentation, bound to its code
111 documents

How the engine works

From the prescriptive architecture to the actual dispatch loop: the 4-stage pipeline as designed, then the code that runs engagements.

src/workflows/executor.ts659 lines · runGenericWorkflow L46–500
Outline 3 symbols
1/**
2 * Generic Workflow Executor — Runs any workflow template.
3 *
4 * v5: This is the generic counterpart to `runTheShem()` in orchestrator.ts.
5 * `runTheShem()` runs the hardcoded legal-design pipeline.
6 * `runGenericWorkflow()` runs any WorkflowTemplate.
7 *
8 * Follows the same pattern as runTheShem():
9 * - Creates session-bound MCP server
10 * - Creates audit/cost/gate hooks
11 * - Builds prompt from template.orchestratorPrompt + request details
12 * - Calls query() with dynamic permissions
13 * - Streams messages to console
14 */
15
16import { query } from '@anthropic-ai/claude-agent-sdk';
17import { retryQuery } from '../utils/retry-query.js';
18import { agentDefinitions } from '../agents/definitions.js';
19import { agentProfiles } from '../agents/profiles.js';
20import { getOrchestratorForWorkflow } from './orchestrator-mapping.js';
21import { createShemMcpServer } from '../mcp/server.js';
22import { createAuditHooks, initAuditLog } from '../hooks/audit-logger.js';
23import { persistAuditEntry } from '../utils/audit-persistence.js';
24import { createCostHooks } from '../hooks/cost-tracker.js';
25import { createGateHooks } from '../hooks/human-gate.js';
26import { createDynamicPermissions } from '../permissions/dynamic-permissions.js';
27import { SessionState } from '../session/session-state.js';
28import { eventTimestamp } from '../events/event-bus.js';
29import { streamMessages } from '../utils/stream-messages.js';
30import { handleSessionError } from '../utils/error-recovery.js';
31import { assembleDocument } from '../assembly/document-assembler.js';
32import { preArchiveSessionRow, updateArchivedDocument } from '../db/database.js';
33import { config } from '../config.js';
34import { runMistralWorkflow } from '../providers/mistral-executor.js';
35import { runLocalWorkflow } from '../providers/local-executor.js';
36import { checkLocalReady } from '../providers/local.js';
37import { existsSync, readFileSync } from 'fs';
38import { join } from 'path';
39import type { LegalRequest, RouterClassification } from '../types/index.js';
40import type { WorkflowTemplate } from '../types/workflow.js';
41import type { SchemOptions } from '../orchestrator.js';
42import { createLogger } from '../utils/logger.js';
43
44const logger = createLogger('EXECUTOR');
45
46export async function runGenericWorkflow(
47 request: LegalRequest,
48 template: WorkflowTemplate,
49 classification: RouterClassification,
50 session: SessionState,
51 options: SchemOptions = {},
52): Promise<SessionState> {
53 // ── Provider Branch — Mistral parallel execution path ──────────────
54 // v18: Per-session provider override (options > session > global config)
55 const provider = options.provider ?? session.provider ?? config.provider;
56
57 // Initialize the audit log + event-stream bridge for mistral/local. The
58 // Anthropic path does its own init below (driven by SDK PostToolUse hooks).
59 // Mistral/local emit through the event bus instead, so we mirror every
60 // emitted event into the audit JSONL — that's what /api/replay reads when
61 // the user clicks "View Agent Work" on a finished case.
62 if (provider === 'mistral' || provider === 'local') {
63 session.workflowTemplateId = template.id;
64 session.legalRequest = request;
65 initAuditLog(session);
66 session.events.on('event', (ev: import('../events/event-bus.js').ShemEvent) => {
67 try {
68 persistAuditEntry(session, ev as unknown as import('../types/audit.js').AuditEntry);
69 } catch { /* best-effort; never break workflow on audit-write failure */ }
70 });
71 }
72
73 // Managed Agents path (Anthropic beta, scaffold only). The executor is not
74 // yet implemented — reject early with a clear message so callers can fall
75 // back or surface the error. Remove this guard once Stage 2 of the managed
76 // agents migration lands (see docs/managed-agents-migration.md).
77 if (provider === 'managed') {
78 throw new Error(
79 'Managed Agents provider is scaffolded but not yet wired. ' +
80 'Set provider to "anthropic" or "mistral" for now.'
81 );
82 }
83
84 if (provider === 'mistral') {
85 try {
86 return await runMistralWorkflow(request, template, classification, session, options);
87 } catch (mistralError) {
88 logger.error('Mistral workflow failed', { error: mistralError });
89 // Emit session_end so frontend isn't stuck waiting
90 session.events.emitEvent({
91 type: 'error',
92 message: `Workflow failed: ${mistralError instanceof Error ? mistralError.message : String(mistralError)}`,
93 source: 'orchestrator',
94 timestamp: eventTimestamp(),
95 });
96 session.events.emitEvent({
97 type: 'session_end',
98 sessionId: session.id,
99 totalCost: session.accumulatedCost,
100 duration: 0,
101 timestamp: eventTimestamp(),
102 });
103 throw mistralError;
104 }
105 }
106
107 if (provider === 'local') {
108 // Pre-flight check — fail fast with a clear error if Ollama isn't running
109 // or the model isn't pulled. Avoids minutes of stalled session before
110 // surfacing a setup problem.
111 const readinessError = await checkLocalReady(config.local.defaultModel);
112 if (readinessError) {
113 session.events.emitEvent({
114 type: 'error',
115 message: `Local provider not ready: ${readinessError}`,
116 source: 'orchestrator',
117 timestamp: eventTimestamp(),
118 });
119 session.events.emitEvent({
120 type: 'session_end',
121 sessionId: session.id,
122 totalCost: 0,
123 duration: 0,
124 timestamp: eventTimestamp(),
125 });
126 throw new Error(readinessError);
127 }
128 try {
129 return await runLocalWorkflow(request, template, classification, session, options);
130 } catch (localError) {
131 logger.error('Local workflow failed', { error: localError });
132 session.events.emitEvent({
133 type: 'error',
134 message: `Workflow failed: ${localError instanceof Error ? localError.message : String(localError)}`,
135 source: 'orchestrator',
136 timestamp: eventTimestamp(),
137 });
138 session.events.emitEvent({
139 type: 'session_end',
140 sessionId: session.id,
141 totalCost: session.accumulatedCost,
142 duration: 0,
143 timestamp: eventTimestamp(),
144 });
145 throw localError;
146 }
147 }
148
149 // ── Anthropic / Claude Agent SDK path (default) ────────────────────
150 const {
151 maxBudgetUsd = config.defaultBudgetUsd,
152 model = config.defaultModel,
153 maxTurns = config.genericMaxTurns,
154 effort,
155 logLevel = config.logLevel,
156 } = options;
157
158 session.budgetUsd = maxBudgetUsd;
159 session.workflowTemplateId = template.id;
160 session.legalRequest = request; // Store for assembly context
161
162 // Initialize audit log
163 initAuditLog(session);
164
165 // Note: debug logging is controlled via the SHEM_LOG_LEVEL env var at startup,
166 // not mutated per-session. The logLevel option is passed through to streamMessages.
167
168 // Emit session start event
169 session.events.emitEvent({
170 type: 'session_start',
171 sessionId: session.id,
172 document: request.documentPath ?? request.requestText ?? '(no document)',
173 timestamp: eventTimestamp(),
174 });
175
176 logger.info('Starting workflow', {
177 sessionId: session.id,
178 workflow: `${template.id} (${template.name})`,
179 requestType: classification.requestType,
180 complexity: classification.complexity,
181 hasDocument: !!request.documentPath,
182 requestLength: request.requestText?.length ?? 0,
183 budget: maxBudgetUsd.toFixed(2),
184 model,
185 specialists: classification.selectedSpecialists.join(', '),
186 });
187
188 // Create session-bound factories (pass template for generic workflow tools + permissions)
189 const shemMcpServer = createShemMcpServer(session, template);
190 const { auditLoggerHook, subagentStartHook, subagentStopHook } = createAuditHooks(session);
191 const { haltCheckHook, costTrackerHook } = createCostHooks(session);
192 const { humanGateEnforcerHook } = createGateHooks(session);
193
194 // Build prompt from template + request (includes document context if documents are loaded)
195 const prompt = buildPromptFromRequest(request, template, classification, session);
196
197 // Filter agent definitions to only those needed by this workflow
198 // v8: When a client has selected a team, use those agents instead of template defaults
199 // v11: Team size cap is now configurable per template (default 14, full-bench allows 25)
200 const DEFAULT_MAX_TEAM_SIZE = 14;
201 const maxTeamSize = template.maxTeamSize ?? DEFAULT_MAX_TEAM_SIZE;
202 const rawTeamRoles = session.selectedTeam.length > 0
203 ? session.selectedTeam
204 : template.requiredAgents;
205 const teamRoles = rawTeamRoles.slice(0, maxTeamSize);
206 if (rawTeamRoles.length > maxTeamSize) {
207 logger.error('Capped team size', { from: rawTeamRoles.length, to: maxTeamSize });
208 }
209 const filteredAgents: Record<string, typeof agentDefinitions[keyof typeof agentDefinitions]> = {};
210 for (const role of teamRoles) {
211 if (role in agentDefinitions) {
212 filteredAgents[role] = agentDefinitions[role as keyof typeof agentDefinitions];
213 } else {
214 logger.warn('Agent requested but not defined — skipping', { role });
215 }
216 }
217 // Always include evaluator if the workflow has evaluator gates
218 const hasEvaluatorGate = Object.values(template.stepDefinitions).some(s => s.requiresEvaluatorGate);
219 if (hasEvaluatorGate && 'evaluator' in agentDefinitions) {
220 filteredAgents['evaluator'] = agentDefinitions['evaluator'];
221 }
222
223 // Sanity check: at least one agent must be available
224 if (Object.keys(filteredAgents).length === 0) {
225 const fallbackTeam = template.requiredAgents;
226 logger.error('No valid agents from selected team — falling back to template defaults', { fallbackTeam: fallbackTeam.join(', ') });
227 for (const role of fallbackTeam) {
228 if (role in agentDefinitions) {
229 filteredAgents[role] = agentDefinitions[role as keyof typeof agentDefinitions];
230 }
231 }
232 if (Object.keys(filteredAgents).length === 0) {
233 throw new Error(`No valid agent definitions found for workflow "${template.id}". Selected team: [${teamRoles.join(', ')}], required: [${fallbackTeam.join(', ')}]`);
234 }
235 }
236
237 // v0.14.5: Inject document context into every subagent's system prompt.
238 // When the orchestrator dispatches a Task to a specialist, the SDK uses the
239 // agent definition's static `prompt`. Without this injection, specialists
240 // had no access to uploaded documents (their tools were unreliable, and the
241 // doc text never reached their context). Specialists then either refused
242 // to analyse or produced doc-blind commentary.
243 //
244 // We clone each agent definition and append a UPLOADED DOCUMENTS block
245 // identical to the orchestrator's, plus the same "quote clauses verbatim"
246 // instruction. This is per-session (mutates filteredAgents only).
247 if (session.documents.length > 0) {
248 const PER_DOC_BUDGET = 60_000; // tighter than orchestrator (subagents are focused)
249 const TOTAL_BUDGET = 150_000;
250 const docBlockParts: string[] = [];
251 let remaining = TOTAL_BUDGET;
252 docBlockParts.push('\n══════════════════════════════════════════════════════════════');
253 docBlockParts.push('UPLOADED DOCUMENTS — full text included for direct reading');
254 docBlockParts.push('══════════════════════════════════════════════════════════════');
255 docBlockParts.push(`${session.documents.length} document(s) available. Read them directly. Quote clause numbers and verbatim text in your findings.\n`);
256 for (let i = 0; i < session.documents.length; i++) {
257 const doc = session.documents[i];
258 const docBudget = Math.min(PER_DOC_BUDGET, remaining);
259 if (docBudget <= 0) {
260 docBlockParts.push(`### Document ${i + 1}: ${doc.name} — [skipped: budget exceeded; use \`read_document_section\` tool]\n`);
261 continue;
262 }
263 const headings = doc.sections.slice(0, 8).map(s => s.heading).join(', ');
264 docBlockParts.push(`### Document ${i + 1}: ${doc.name}`);
265 docBlockParts.push(` ${doc.pageCount} pages · ${doc.wordCount.toLocaleString()} words${headings ? ` · sections: ${headings}` : ''}`);
266 const text = doc.fullText ?? '';
267 if (text.length <= docBudget) {
268 docBlockParts.push('--- BEGIN ' + doc.name + ' ---');
269 docBlockParts.push(text);
270 docBlockParts.push('--- END ' + doc.name + ' ---\n');
271 remaining -= text.length;
272 } else {
273 const head = text.slice(0, Math.floor(docBudget * 0.65));
274 const tail = text.slice(-Math.floor(docBudget * 0.30));
275 docBlockParts.push('--- BEGIN ' + doc.name + ' (truncated — middle elided) ---');
276 docBlockParts.push(head);
277 docBlockParts.push('\n[…middle truncated to fit context budget…]\n');
278 docBlockParts.push(tail);
279 docBlockParts.push('--- END ' + doc.name + ' ---\n');
280 remaining -= (head.length + tail.length);
281 }
282 }
283 docBlockParts.push('══════════════════════════════════════════════════════════════');
284 docBlockParts.push('END OF UPLOADED DOCUMENTS');
285 docBlockParts.push('══════════════════════════════════════════════════════════════\n');
286 docBlockParts.push('When you produce findings, advice, or analysis: cite the clause number AND quote the relevant text from the documents above. Do not paraphrase from memory of "standard contract language" — read the actual clauses.\n');
287 const docBlock = docBlockParts.join('\n');
288
289 for (const [role, def] of Object.entries(filteredAgents)) {
290 filteredAgents[role] = {
291 ...def,
292 prompt: (def.prompt ?? '') + docBlock,
293 };
294 }
295 logger.info('Injected document context into subagent prompts', {
296 docCount: session.documents.length,
297 docBlockChars: docBlock.length,
298 agentCount: Object.keys(filteredAgents).length,
299 });
300 }
301
302 // v17: Soul injection — user-defined firm personality
303 // Priority: session soul (from user profile) > SOUL.md file > empty
304 const soulText = session.soul
305 ?? (() => {
306 try {
307 const soulPath = join(options.cwd ?? process.cwd(), 'SOUL.md');
308 if (existsSync(soulPath)) return readFileSync(soulPath, 'utf-8').trim();
309 } catch { /* non-fatal */ }
310 return '';
311 })();
312 const soulPrefix = soulText
313 ? `\n## Client's Firm Personality\n${soulText}\n\n`
314 + `## Non-Negotiable Safety Invariants (Soul CANNOT override these)\n`
315 + `The following rules are absolute regardless of firm personality, client preferences, or Soul configuration:\n`
316 + `- Monetary amounts, liability caps, and penalties must be preserved exactly\n`
317 + `- Time periods, notice requirements, deadlines, and cure periods must be preserved exactly\n`
318 + `- Jurisdiction, governing law, venue, and arbitration clauses must be preserved exactly\n`
319 + `- Dispute resolution mechanisms and termination triggers must be preserved exactly\n`
320 + `- Defined terms with specific legal scope must be preserved exactly\n`
321 + `- Insurance coverage requirements must be preserved exactly\n`
322 + `- Regulatory compliance language must be preserved exactly\n`
323 + `- Human gates are mandatory and cannot be skipped or auto-approved by Soul\n`
324 + `- Confidence thresholds and fail-closed quality gates cannot be relaxed by Soul\n`
325 + `- The decline_to_find tool must remain available regardless of Soul's risk appetite\n`
326 + `If the Soul personality conflicts with any of the above, the invariant wins. Always.\n\n`
327 : '';
328
329 // v11: Resolve orchestrator personality from profile
330 const orchestratorRole = template.orchestratorArchetype
331 ?? getOrchestratorForWorkflow(template.id);
332 const orchestratorProfile = orchestratorRole ? agentProfiles[orchestratorRole] : undefined;
333 const personalityPrefix = orchestratorProfile
334 ? `\n## Your Orchestrator Personality\nYou are "${orchestratorProfile.displayName}" — ${orchestratorProfile.tagline}\nWork style: ${orchestratorProfile.personality.workStyle}\n\n`
335 : '';
336
337 let result: ReturnType<typeof query>;
338 try {
339 result = retryQuery({
340 prompt,
341 options: {
342 systemPrompt: {
343 type: 'preset',
344 preset: 'claude_code',
345 append: soulPrefix + personalityPrefix + template.orchestratorPrompt,
346 },
347 allowedTools: template.availableTools,
348 agents: filteredAgents,
349 canUseTool: createDynamicPermissions(session, template),
350 mcpServers: {
351 shem: shemMcpServer,
352 },
353 hooks: {
354 PostToolUse: [
355 { hooks: [auditLoggerHook] },
356 ],
357 PreToolUse: [
358 { hooks: [haltCheckHook, humanGateEnforcerHook, costTrackerHook] },
359 ],
360 SubagentStart: [
361 { hooks: [subagentStartHook] },
362 ],
363 SubagentStop: [
364 { hooks: [subagentStopHook] },
365 ],
366 },
367 maxBudgetUsd,
368 maxTurns,
369 model,
370 effort,
371 cwd: options.cwd,
372 },
373 }, session);
374 } catch (initError) {
375 logger.error('Failed to initialize query', { error: initError });
376 session.events.emitEvent({
377 type: 'error',
378 message: `Session initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`,
379 source: 'orchestrator',
380 timestamp: eventTimestamp(),
381 });
382 session.events.emitEvent({
383 type: 'session_end',
384 sessionId: session.id,
385 totalCost: 0,
386 duration: 0,
387 timestamp: eventTimestamp(),
388 });
389 throw initError;
390 }
391
392 // Stream messages to console (suppress session_end — we emit it after assembly)
393 let pipelineCost = 0;
394 let pipelineDurationMs = 0;
395 try {
396 await streamMessages(result, {
397 session,
398 documentLabel: request.documentPath ?? '(no document)',
399 workflowLabel: template.id,
400 logLevel,
401 suppressSessionEnd: true,
402 });
403 pipelineCost = session.accumulatedCost;
404 } catch (error) {
405 const sessionError = handleSessionError(session, error);
406 logger.error('Workflow error', { workflow: template.id, step: sessionError.step, error: sessionError.cause });
407
408 // Tier 4: partial findings from interrupted analysis (or 4 with zero findings)
409 session.outputTier = 4;
410 session.outputTierReason = session.debate.findings.length > 0
411 ? `Workflow error at step "${sessionError.step}". ${session.debate.findings.length} finding(s) preserved.`
412 : `Workflow error at step "${sessionError.step}". No findings were produced before the error.`;
413
414 // Still emit session_end on error so frontend isn't stuck,
415 // but guard against double emission (streamMessages may have already emitted it)
416 if (session.workflow?.currentStep !== 'delivered') {
417 session.events.emitEvent({
418 type: 'session_end',
419 sessionId: session.id,
420 totalCost: session.accumulatedCost,
421 duration: 0,
422 timestamp: eventTimestamp(),
423 });
424 }
425 throw error;
426 }
427
428 // ── v15: Document Assembly — produce the actual deliverable ────────
429 // After the multi-agent pipeline completes, make a focused Claude call
430 // to assemble the ACTUAL document from all the structured analysis.
431 // This is what makes Lavern's output better than a single prompt:
432 // the assembly has ALL the multi-agent intelligence as context.
433 // At this point, multi-agent analysis is complete. Set tier 3 (raw findings available).
434 session.outputTier = 3;
435 session.outputTierReason = 'Analysis complete, assembling deliverable';
436
437 // Pre-archive the session row NOW (before assembly begins) so the delivery view
438 // can find the session even if the server restarts during the multi-minute assembly.
439 // Billing still happens at session_end — this just writes the row.
440 try {
441 const preArchiveUserId = session.userId ?? session.clientIdentity?.id ?? null;
442 preArchiveSessionRow(session, preArchiveUserId);
443 } catch (preArchiveErr) {
444 logger.warn('Pre-archive failed (non-fatal)', { error: preArchiveErr });
445 }
446 session.isAssembling = true;
447
448 try {
449 session.assembledDocument = await assembleDocument(session, request);
450
451 if (session.assembledDocument) {
452 // Check if assembly used bestAttempt fallback (tier 2) vs full pass (tier 1)
453 // The assembler logs warnings when using bestAttempt — check for them
454 session.outputTier = 1;
455 session.outputTierReason = 'Full deliverable produced';
456 } else {
457 // Assembly returned empty — tier 3 (findings only)
458 session.outputTier = 3;
459 session.outputTierReason = 'Assembly could not produce a deliverable. Raw findings and debate available.';
460 session.events.emitEvent({
461 type: 'error',
462 message: 'Document assembly could not produce a deliverable. You can retry from the delivery view.',
463 source: 'document-assembler',
464 timestamp: eventTimestamp(),
465 });
466 }
467 } catch (assemblyError) {
468 logger.error('Document assembly failed (non-fatal)', { error: assemblyError });
469 // Tier 3: analysis is available even though assembly failed
470 session.outputTier = 3;
471 session.outputTierReason = `Assembly error: ${assemblyError instanceof Error ? assemblyError.message : 'Unknown'}. Raw findings and debate available.`;
472 session.events.emitEvent({
473 type: 'error',
474 message: `Document assembly error: ${assemblyError instanceof Error ? assemblyError.message : String(assemblyError)}`,
475 source: 'document-assembler',
476 timestamp: eventTimestamp(),
477 });
478 } finally {
479 session.isAssembling = false;
480 // Update the pre-archived row with the assembled document (if any) and final cost.
481 // This runs even if assembly failed — it updates cost and marks row as completed.
482 try {
483 const stepsDone = (session.genericWorkflow?.completedSteps ?? session.workflow.completedSteps).length;
484 updateArchivedDocument(session.id, session.assembledDocument, session.accumulatedCost, stepsDone);
485 } catch (updateErr) {
486 logger.warn('Archive document update failed (non-fatal)', { error: updateErr });
487 }
488 }
489
490 // NOW emit session_end — assembly is complete, deliverable is ready
491 session.events.emitEvent({
492 type: 'session_end',
493 sessionId: session.id,
494 totalCost: session.accumulatedCost,
495 duration: 0,
496 timestamp: eventTimestamp(),
497 });
498
499 return session;
500}
501
502/**
503 * Build the orchestrator prompt from a request and template.
504 */
505function buildPromptFromRequest(
506 request: LegalRequest,
507 template: WorkflowTemplate,
508 classification: RouterClassification,
509 session: SessionState,
510): string {
511 const parts: string[] = [];
512
513 // Request details
514 if (request.documentPath) {
515 parts.push(`Analyze the document at: ${request.documentPath}`);
516 }
517 if (request.requestText) {
518 parts.push(`Request: ${request.requestText}`);
519 }
520
521 // Context
522 if (request.context) {
523 const ctx = request.context;
524 const contextParts: string[] = [];
525 if (ctx.moment) contextParts.push(`**Moment**: ${ctx.moment}`);
526 if (ctx.audience) contextParts.push(`**Audience**: ${ctx.audience}`);
527 if (ctx.jurisdiction) contextParts.push(`**Jurisdiction**: ${ctx.jurisdiction}`);
528 if (ctx.documentType) contextParts.push(`**Document Type**: ${ctx.documentType}`);
529 if (ctx.focus) contextParts.push(`**Focus Area**: ${ctx.focus}`);
530 if (contextParts.length > 0) {
531 parts.push(`\nContext:\n${contextParts.map(c => `- ${c}`).join('\n')}`);
532 }
533 }
534
535 // v0.14.5: Documents are embedded INLINE in the prompt — not gated behind tools.
536 //
537 // Why: every workflow's prompt previously said "use list_documents / read_document_section
538 // to access content". When those tools were missing from a template's allowlist
539 // OR errored at runtime, the orchestrator would either (a) refuse to answer
540 // because it claimed it could not access the documents, or (b) hallucinate from
541 // the request text. Both produced unusable output.
542 //
543 // With Opus 4.7's 1M-token window, embedding ~150k chars of document content
544 // directly is trivial and removes an entire class of failure modes. Tools remain
545 // available as a backup for very large multi-doc cases (>200k chars total).
546 if (session.documents.length > 0) {
547 const PER_DOC_BUDGET = 90_000; // chars — generous for one ToS / contract
548 const TOTAL_BUDGET = 240_000; // chars across all docs (~60k tokens)
549 let remainingBudget = TOTAL_BUDGET;
550
551 parts.push('\n══════════════════════════════════════════════════════════════');
552 parts.push('UPLOADED DOCUMENTS — full text included below for direct reading');
553 parts.push('══════════════════════════════════════════════════════════════');
554 parts.push(`${session.documents.length} document(s) attached. Read them directly. The complete clause-by-clause text is in this prompt; you do NOT need a tool call to access it. Quote clause numbers and verbatim phrases when you advise.\n`);
555
556 for (let i = 0; i < session.documents.length; i++) {
557 const doc = session.documents[i];
558 const docBudget = Math.min(PER_DOC_BUDGET, remainingBudget);
559 if (docBudget <= 0) {
560 parts.push(`### Document ${i + 1}: ${doc.name} — [skipped: total document budget exceeded; use \`read_document_section\` tool to access]`);
561 continue;
562 }
563 const headings = doc.sections.slice(0, 10).map(s => s.heading).join(', ');
564 parts.push(`### Document ${i + 1}: ${doc.name}`);
565 parts.push(` ${doc.pageCount} pages · ${doc.wordCount.toLocaleString()} words${headings ? ` · sections: ${headings}` : ''}`);
566 if (doc.definedTerms.length > 0) {
567 parts.push(` Defined terms: ${doc.definedTerms.slice(0, 12).join(', ')}${doc.definedTerms.length > 12 ? '…' : ''}`);
568 }
569 parts.push('');
570
571 const text = doc.fullText ?? '';
572 if (text.length <= docBudget) {
573 parts.push('--- BEGIN ' + doc.name + ' ---');
574 parts.push(text);
575 parts.push('--- END ' + doc.name + ' ---\n');
576 remainingBudget -= text.length;
577 } else {
578 // Doc is bigger than per-doc budget: keep head + tail, surface table of contents
579 const head = text.slice(0, Math.floor(docBudget * 0.65));
580 const tail = text.slice(-Math.floor(docBudget * 0.30));
581 parts.push('--- BEGIN ' + doc.name + ' (truncated — see tool calls for missing sections) ---');
582 parts.push(head);
583 parts.push('\n[…middle of document truncated to fit context budget — use `read_document_section` tool with section heading to access specific clauses…]\n');
584 parts.push(tail);
585 parts.push('--- END ' + doc.name + ' ---\n');
586 remainingBudget -= (head.length + tail.length);
587 }
588 }
589 parts.push('══════════════════════════════════════════════════════════════');
590 parts.push('END OF UPLOADED DOCUMENTS');
591 parts.push('══════════════════════════════════════════════════════════════\n');
592 parts.push('When you produce findings, advice, or deliverables: cite the clause number AND quote the relevant text from the documents above. Do not paraphrase from memory of "standard contract language" — read the actual clauses. Document tools (`list_documents`, `read_document_section`, `search_document`) are available if you need them, but the content is already in this prompt.\n');
593 }
594
595 // Classification info
596 parts.push(`\nRouter Classification:`);
597 parts.push(`- Request Type: ${classification.requestType}`);
598 parts.push(`- Complexity: ${classification.complexity}`);
599 parts.push(`- Risk Level: ${classification.riskLevel}`);
600 parts.push(`- Selected Specialists: ${classification.selectedSpecialists.join(', ')}`);
601 if (classification.requiresDebate) parts.push(`- Debate rounds required`);
602 if (classification.requiresEthicsFirst) parts.push(`- Ethics-first review required`);
603 if (classification.requiresConsistencyCheck) parts.push(`- Consistency check required`);
604
605 // Workflow instructions
606 parts.push(`\nFollow the ${template.id} workflow. Start by calling \`get_current_step\` to see where you are.`);
607 parts.push(`Use \`advance_step\` after completing each step.`);
608
609 // Step summary
610 parts.push(`\nWorkflow Steps (${template.steps.length}):`);
611 template.steps.forEach((step, i) => {
612 const def = template.stepDefinitions[step];
613 const flags: string[] = [];
614 if (def?.requiresGateApproval) flags.push('[HUMAN GATE]');
615 if (def?.requiresEvaluatorGate) flags.push('[EVALUATOR GATE]');
616 parts.push(`${i + 1}. ${step}${def?.description ?? ''} ${flags.join(' ')}`);
617 });
618
619 // ── Team Critical Rules & Success Metrics ─────────────────────────────
620 // Gives the orchestrator awareness of each team member's constraints
621 const teamRulesSection: string[] = [];
622 // Must match the agents actually made dispatchable to the SDK (filteredAgents
623 // uses selectedTeam || template.requiredAgents). Using
624 // classification.selectedSpecialists here briefed the orchestrator on rules
625 // for agents it couldn't dispatch — and omitted rules for ones it could.
626 const teamRoles = session.selectedTeam.length > 0
627 ? session.selectedTeam
628 : template.requiredAgents;
629 for (const role of teamRoles) {
630 const profile = agentProfiles[role];
631 if (profile?.criticalRules?.length || profile?.successMetrics?.length) {
632 teamRulesSection.push(`\n### ${profile.displayName} (${role})`);
633 if (profile.criticalRules?.length) {
634 teamRulesSection.push(`**Critical Rules:**`);
635 profile.criticalRules.forEach(r => teamRulesSection.push(`- ${r}`));
636 }
637 if (profile.successMetrics?.length) {
638 teamRulesSection.push(`**Success Metrics:**`);
639 profile.successMetrics.forEach(m => teamRulesSection.push(`- ${m}`));
640 }
641 }
642 }
643 if (teamRulesSection.length > 0) {
644 parts.push(`\n## Team Critical Rules & Success Metrics`);
645 parts.push(...teamRulesSection);
646 }
647
648 // ── Handoff Protocol ──────────────────────────────────────────────────
649 parts.push(`\n## Handoff Protocol`);
650 parts.push(`Before calling \`advance_step\`, ALWAYS call \`submit_handoff\` to record a structured summary of what happened in the completing step — key outputs, deliverables produced, open items for the next phase, and a confidence score.`);
651 parts.push(`At the START of each new step, call \`get_handoffs\` to review what previous phases produced and what needs attention.`);
652
653 // ── Memory Tagging ────────────────────────────────────────────────────
654 parts.push(`\n## Memory Tagging`);
655 parts.push(`When saving to institutional memory or precedents, include tags: agent_role (the saving agent's role), engagement_type ("${template.id}"), document_type, and jurisdiction from context. This enables filtered retrieval in future engagements.`);
656
657 return parts.join('\n').trim();
658}
659