Files
open-multi-agent-open-multi…/examples/cookbook/contract-review-dag.ts
T
Segmentandyanzizheng 608170345d feat: add contract-review-dag cookbook example with step-level retry (#155)
* feat: add an example for force fail

* fix:modify README.md. remove inapporiate desc and wait in contract-review-dag.ts.

* add the diff time assert.add two newline at the file end.

---------

Co-authored-by: yanzizheng <yanzizheng@xiaoice.com>
2026-04-24 17:49:15 +08:00

369 lines
12 KiB
TypeScript

/**
* Contract Review DAG with Step-Level Retry
*
* Demonstrates DAG task orchestration with step-level retry using runTasks().
* Scenario: contract review pipeline with 4 tasks forming a DAG:
*
* [Task 1: extract-clauses]
* ├──→ [Task 2: compliance-check] ────┐
* │ ├──→ [Task 4: notify] → Markdown
* └──→ [Task 3: summary] ──────────────┘
*
* Key features:
* - DAG dependencies with parallel execution (Task 2 and 3 run concurrently)
* - Step-level retry on Task 2/3/4 with exponential backoff
* - FORCE_FAIL=task2 env var triggers Task 2 failure on first attempt
*
* Run:
* npx tsx examples/cookbook/contract-review-dag.ts
* FORCE_FAIL=task2 npx tsx examples/cookbook/contract-review-dag.ts
*
* Prerequisites:
* ANTHROPIC_API_KEY env var must be set.
*/
import { OpenMultiAgent } from '../../src/index.js'
import type { AgentConfig, OrchestratorEvent } from '../../src/types.js'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// ---------------------------------------------------------------------------
// Attempt counter for FORCE_FAIL mechanism (closure-based per agent)
// ---------------------------------------------------------------------------
const attemptCounter = new Map<string, number>()
function getAndIncrementAttempt(agentName: string): number {
const count = (attemptCounter.get(agentName) ?? 0) + 1
attemptCounter.set(agentName, count)
return count
}
// ---------------------------------------------------------------------------
// Step 1: Agent configurations (4 agents)
// ---------------------------------------------------------------------------
const extractorConfig: AgentConfig = {
name: 'extractor',
model: 'claude-sonnet-4-6',
provider: 'anthropic',
systemPrompt: `You are a contract clause extraction specialist. Extract all clauses from the provided contract text and output as a JSON array.
Each object must have:
- id: clause number (string)
- title: clause title
- content: full clause text
- riskLevel: "low" | "medium" | "high"
Output ONLY valid JSON, no other text.`,
maxTurns: 1,
temperature: 0.1,
}
const complianceCheckerConfig: AgentConfig = {
name: 'compliance-checker',
model: 'claude-sonnet-4-6',
provider: 'anthropic',
systemPrompt: `You are a contract compliance auditor. Review each clause for regulatory and operational compliance.
For each clause, output:
- clauseId: clause number
- isCompliant: true/false
- issues: array of issues found (empty array if none)
- riskCategory: "none" | "regulatory" | "operational" | "legal"
Output ONLY valid JSON array, no other text.`,
maxTurns: 1,
temperature: 0.1,
beforeRun: (context) => {
const attempt = getAndIncrementAttempt('compliance-checker')
// Only trigger FORCE_FAIL on first attempt (attempt=1)
if (attempt === 1 && process.env.FORCE_FAIL === 'task2') {
throw new Error('[FORCE_FAIL_TRIGGERED]')
}
return context
},
}
const summarizerConfig: AgentConfig = {
name: 'summarizer',
model: 'claude-sonnet-4-6',
provider: 'anthropic',
systemPrompt: `You are a contract summary specialist. Generate an executive summary from the extracted clause list.
Include:
- Contract overview (main areas covered)
- Key clause summary (3-5 most important points)
- Risk callouts (highlight high-risk clauses)
- Recommended next steps
Output in Markdown format.`,
maxTurns: 1,
temperature: 0.2,
}
const notifierConfig: AgentConfig = {
name: 'notifier',
model: 'claude-sonnet-4-6',
provider: 'anthropic',
systemPrompt: `You are a report writer. Combine the compliance check results and summary into a final contract review report.
Include:
- Executive Summary
- Compliance Results
- Risk Details
- Recommended Actions
Output in Markdown format.`,
maxTurns: 1,
temperature: 0.2,
}
// ---------------------------------------------------------------------------
// Step 2: Task configurations (4 tasks)
// ---------------------------------------------------------------------------
interface TaskConfig {
title: string
description: string
assignee?: string
dependsOn?: readonly string[]
maxRetries?: number
retryDelayMs?: number
retryBackoff?: number
}
// Task configs will be created after reading contract text
// ---------------------------------------------------------------------------
// Step 3: Progress tracking
// ---------------------------------------------------------------------------
interface TaskTiming {
startTime: number
endTime?: number
}
const taskTimings = new Map<string, TaskTiming>()
const taskStartTimes = new Map<string, number>()
function handleProgress(event: OrchestratorEvent): void {
const ts = new Date().toISOString()
switch (event.type) {
case 'task_start': {
const task = event.data as { id: string; title: string } | undefined
if (task) {
const now = Date.now()
taskTimings.set(task.id, { startTime: now })
taskStartTimes.set(task.title, now) // Use title as key for easier verification
console.log(`[${ts}] task_start: ${task.title}`)
}
break
}
case 'task_complete': {
const task = event.data as { id: string; title: string } | undefined
if (task) {
const timing = taskTimings.get(task.id)
if (timing) {
timing.endTime = Date.now()
const duration = timing.endTime - timing.startTime
console.log(`[${ts}] task_complete: ${task.title} (${duration}ms)`)
}
}
break
}
case 'task_retry':
console.log(`[${ts}] task_retry:`, JSON.stringify(event.data))
break
case 'task_skipped':
console.log(`[${ts}] task_skipped: ${event.task}`)
break
case 'agent_start':
if (event.agent) {
console.log(`[${ts}] agent_start: ${event.agent}`)
}
break
case 'agent_complete':
if (event.agent) {
console.log(`[${ts}] agent_complete: ${event.agent}`)
}
break
case 'message':
if (typeof event.data === 'string') {
console.log(`[${ts}] message: ${event.data}`)
}
break
case 'error':
console.log(`[${ts}] error:`, JSON.stringify(event.data))
break
default:
break
}
}
// ---------------------------------------------------------------------------
// Step 4: Parallelism verification
// ---------------------------------------------------------------------------
function verifyParallelism(): void {
const t2Start = taskStartTimes.get('compliance-check')
const t3Start = taskStartTimes.get('summary')
if (t2Start !== undefined && t3Start !== undefined) {
const diff = Math.abs(t2Start - t3Start)
console.log('\n=== Parallelism Check ===')
console.log(`Task 2 (compliance-check) start: ${t2Start}`)
console.log(`Task 3 (summary) start: ${t3Start}`)
console.log(`Time difference: ${diff}ms`)
if (diff >= 500) {
console.error(
`ASSERTION FAILED: Task 2 and Task 3 start times differ by ${diff}ms (>= 500ms). ` +
`Expected parallel execution after Task 1 completes.`,
)
process.exit(1)
}
console.log(`Parallel execution (< 500ms): YES`)
console.log('========================\n')
} else {
console.log('\n=== Parallelism Check: Unable to verify (missing timing data) ===\n')
}
}
// ---------------------------------------------------------------------------
// Step 5: Orchestrator and Team configuration
// ---------------------------------------------------------------------------
const orchestrator = new OpenMultiAgent({
defaultModel: 'claude-sonnet-4-6',
defaultProvider: 'anthropic',
onProgress: handleProgress,
})
const team = orchestrator.createTeam('contract-review-team', {
name: 'contract-review-team',
agents: [extractorConfig, complianceCheckerConfig, summarizerConfig, notifierConfig],
sharedMemory: true,
})
// ---------------------------------------------------------------------------
// Step 6: Read contract text
// ---------------------------------------------------------------------------
const contractText = readFileSync(
join(__dirname, '..', 'fixtures', 'sample-contract.txt'),
'utf-8',
)
// ---------------------------------------------------------------------------
// Step 6b: Create task configs with contract text
// ---------------------------------------------------------------------------
const taskConfigs: TaskConfig[] = [
{
title: 'extract-clauses',
description: `Extract all clauses from the following contract text into structured JSON.\n\n=== CONTRACT TEXT ===\n${contractText}\n=== END CONTRACT ===\n\nOutput only valid JSON array.`,
assignee: 'extractor',
},
{
title: 'compliance-check',
description: 'Check each clause for regulatory and operational compliance. Using the clause list from Task 1 above.',
assignee: 'compliance-checker',
dependsOn: ['extract-clauses'],
maxRetries: 2,
retryDelayMs: 500,
retryBackoff: 2,
},
{
title: 'summary',
description: 'Generate executive summary of the contract. Using the clause list from Task 1 above.',
assignee: 'summarizer',
dependsOn: ['extract-clauses'],
maxRetries: 2,
retryDelayMs: 500,
retryBackoff: 2,
},
{
title: 'notify',
description: 'Generate final markdown report with all analysis results',
assignee: 'notifier',
dependsOn: ['compliance-check', 'summary'],
maxRetries: 2,
retryDelayMs: 500,
retryBackoff: 2,
},
]
console.log('Contract Review DAG with Step-Level Retry')
console.log('='.repeat(60))
console.log(`\nContract: ${contractText.split('\n')[0]}`)
console.log(`Length: ${contractText.split(/\s+/).length} words\n`)
console.log(`FORCE_FAIL mode: ${process.env.FORCE_FAIL === 'task2' ? 'ENABLED (task2)' : 'disabled'}\n`)
// ---------------------------------------------------------------------------
// Step 7: Run the DAG
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
try {
console.log('[Orchestration] Starting DAG execution...\n')
const result = await orchestrator.runTasks(team, taskConfigs)
// Verify parallelism
verifyParallelism()
console.log('\n' + '='.repeat(60))
console.log('FINAL RESULT')
console.log('='.repeat(60))
console.log(`Success: ${result.success}`)
console.log(`Total tasks: ${result.tasks?.length ?? taskConfigs.length}`)
console.log(`Total token usage: ${result.totalTokenUsage.input_tokens} input, ${result.totalTokenUsage.output_tokens} output`)
// Output each agent's result
console.log('\n' + '='.repeat(60))
console.log('AGENT RESULTS')
console.log('='.repeat(60))
for (const [agentName, agentResult] of result.agentResults) {
console.log(`\n--- ${agentName} ---`)
console.log(agentResult.success ? agentResult.output : `[FAILED] ${agentResult.output}`)
}
if (result.success) {
console.log('\n--- Final Report ---')
const notifierResult = result.agentResults.get('notifier')
if (notifierResult?.output) {
console.log(notifierResult.output)
}
} else {
console.log('\nWorkflow failed.')
for (const task of result.tasks ?? []) {
if (task.status === 'failed') {
console.log(` - ${task.title}: failed`)
}
}
}
console.log('\n' + '='.repeat(60))
console.log('Done.')
} catch (error) {
console.error('Fatal error:', error)
process.exit(1)
}
}
main()