diff --git a/examples/README.md b/examples/README.md index 47f458a..f158943 100644 --- a/examples/README.md +++ b/examples/README.md @@ -48,7 +48,6 @@ Reusable shapes for common multi-agent problems. | [`patterns/research-aggregation`](patterns/research-aggregation.ts) | Multi-source research collated by a synthesis agent. | | [`patterns/cost-tiered-pipeline`](patterns/cost-tiered-pipeline.ts) | Run the same four-stage pipeline twice to compare flagship vs tiered model cost. | | [`patterns/agent-handoff`](patterns/agent-handoff.ts) | Synchronous sub-agent delegation via `delegate_to_agent`. | - ## cookbook — use-case recipes End-to-end examples framed around a concrete problem (meeting summarization, translation QA, competitive monitoring, etc.) rather than a single orchestration primitive. Lighter bar than `production/`: no tests or pinned model versions required. Good entry point if you want to see how the patterns compose on a real task. @@ -59,6 +58,7 @@ End-to-end examples framed around a concrete problem (meeting summarization, tra | [`cookbook/contract-review-dag`](cookbook/contract-review-dag.ts) | 4-task DAG (extract → compliance-check + summary → notify) with step-level retry. Run normally or with `FORCE_FAIL=task2` to exercise retry. | | [`cookbook/incident-postmortem-dag`](cookbook/incident-postmortem-dag.ts) | 5-task DAG with three parallel root tasks (log patterns + deploy correlation + blast radius) feeding root-cause hypothesis and final postmortem synthesis. | | [`cookbook/competitive-monitoring`](cookbook/competitive-monitoring.ts) | Parallel source monitoring (Twitter/Reddit/News), contradiction detection, and aggregated intelligence reporting. | +| [`cookbook/personalized-interview-simulator`](cookbook/personalized-interview-simulator.ts) | Interactive interviewer loop with observer flags, shared memory, and structured debrief. | ## integrations — external systems diff --git a/examples/cookbook/personalized-interview-simulator.ts b/examples/cookbook/personalized-interview-simulator.ts new file mode 100644 index 0000000..d978fdc --- /dev/null +++ b/examples/cookbook/personalized-interview-simulator.ts @@ -0,0 +1,274 @@ +/** + * Personalized Interview Simulator (Interviewer + Observer) + * + * Demonstrates: + * - A stateful `interviewer` agent using `Agent.prompt()` across turns + * - A stateless `observer` agent that reads full transcript context between turns + * - Manual `SharedMemory` seeding and prompt injection outside `runTeam()` / `runTasks()` + * - Human input handled at app level with `readline` + * - Structured debrief via Zod at the end of the interview loop + * + * Run: + * npx tsx examples/cookbook/personalized-interview-simulator.ts + * + * Prerequisites: + * ANTHROPIC_API_KEY env var must be set. + * + * Optional: + * Set INTERVIEW_CANDIDATE_DIR to point at your own materials directory. + * Expected files: + * - resume.md + * - project-notes.md + * - code.ts + * - job-description.md + */ + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { createInterface } from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' +import { fileURLToPath } from 'node:url' +import { z } from 'zod' +import { Agent, SharedMemory, ToolExecutor, ToolRegistry } from '../../src/index.js' +import type { AgentConfig } from '../../src/types.js' + +// --------------------------------------------------------------------------- +// Candidate materials +// --------------------------------------------------------------------------- + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const DEFAULT_CANDIDATE_DIR = path.join(__dirname, '../fixtures/interview-candidate') +const CANDIDATE_DIR = process.env.INTERVIEW_CANDIDATE_DIR ?? DEFAULT_CANDIDATE_DIR +const MAX_TURNS = 10 + +function loadText(fileName: string): string { + return readFileSync(path.join(CANDIDATE_DIR, fileName), 'utf-8').trim() +} + +const resumeText = loadText('resume.md') +const projectNotes = loadText('project-notes.md') +const codeSnippet = loadText('code.ts') +const jobDescription = loadText('job-description.md') + +// --------------------------------------------------------------------------- +// Structured debrief +// --------------------------------------------------------------------------- + +const DebriefSchema = z.object({ + questions_asked: z.array( + z.object({ + question: z.string(), + why_it_mattered: z.string(), + }), + ), + weak_spots: z.array(z.string()), + strong_spots: z.array(z.string()), + overall_assessment: z.object({ + recommendation: z.enum(['strong-hire', 'hire', 'lean-hire', 'lean-no-hire', 'no-hire']), + summary: z.string(), + }), +}) +type Debrief = z.infer + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildAgent(config: AgentConfig): Agent { + const registry = new ToolRegistry() + const executor = new ToolExecutor(registry) + return new Agent(config, registry, executor) +} + +async function readlineFromCandidate(prompt: string, rl: ReturnType): Promise { + console.log('\n' + '='.repeat(60)) + console.log('INTERVIEWER') + console.log('='.repeat(60)) + console.log(prompt) + console.log() + + const answer = await rl.question('Candidate > ') + return answer.trim() +} + +function isExitAnswer(answer: string): boolean { + const normalized = answer.trim().toLowerCase() + return normalized === 'exit' || normalized === 'quit' +} + +// --------------------------------------------------------------------------- +// Agent configs +// --------------------------------------------------------------------------- + +if (!process.env.ANTHROPIC_API_KEY) { + console.log('[skip] This example needs ANTHROPIC_API_KEY.') + process.exit(0) +} + +const interviewerConfig: AgentConfig = { + name: 'interviewer', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + systemPrompt: `You are a senior technical interviewer. + +You are running a personalized interview grounded in: +- the candidate's resume +- project notes +- a code sample +- the target job description +- observer flags written after each turn + +Rules: +- Ask exactly one question per turn. +- Prefer probing follow-ups over switching topics too early. +- Use the role expectations and candidate materials together. +- If the observer flags a contradiction, vague answer, or missing dimension, use that. +- Keep each question concise but sharp. +- Do not answer your own question. +- Do not output analysis, just the next interview question.`, + maxTurns: 2, + temperature: 0.3, +} + +const observerConfig: AgentConfig = { + name: 'observer', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + systemPrompt: `You are an interview observer. + +You will receive shared memory containing: +- candidate resume +- project notes +- code sample +- target job spec +- the full interview transcript so far + +Write compact flags for the next interviewer turn. +Focus on: +- contradictions with candidate claims +- vague or incomplete answers +- important dimensions not yet tested +- specific follow-up hooks hidden in the answer + +Output 3-6 short bullets. Be concrete. No prose introduction.`, + maxTurns: 1, + temperature: 0.2, +} + +const reporterConfig: AgentConfig = { + name: 'reporter', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + systemPrompt: `You are writing a compact interview debrief. + +Use the complete interview record to produce: +- questions_asked: the most important questions and why each mattered +- weak_spots: concise bullets for weak areas +- strong_spots: concise bullets for strengths +- overall_assessment: recommendation plus a short summary + +Return JSON only, matching the schema exactly.`, + maxTurns: 2, + temperature: 0.1, + outputSchema: DebriefSchema, +} + +// --------------------------------------------------------------------------- +// Seed memory +// --------------------------------------------------------------------------- + +const mem = new SharedMemory() + +await mem.write('candidate', 'resume', resumeText) +await mem.write('candidate', 'project-notes', projectNotes) +await mem.write('candidate', 'code', codeSnippet) +await mem.write('role', 'target-job-spec', jobDescription) + +const interviewer = buildAgent(interviewerConfig) +const observer = buildAgent(observerConfig) +const reporter = buildAgent(reporterConfig) + +const rl = createInterface({ input, output }) + +console.log('Personalized Interview Simulator') +console.log('='.repeat(60)) +console.log(`Candidate materials: ${CANDIDATE_DIR}`) +console.log('Type "exit" or "quit" to stop early.') +console.log() + +// --------------------------------------------------------------------------- +// Interactive loop +// --------------------------------------------------------------------------- + +let answer = '' +let turnsCompleted = 0 + +try { + for (let turn = 0; turn < MAX_TURNS; turn++) { + const ctx = await mem.getSummary() + const result = await interviewer.prompt( + turn === 0 + ? [ + `Context:\n${ctx}`, + 'Ask the most probing opening question you can justify from the materials and role.', + ].join('\n\n') + : [ + `Context:\n${ctx}`, + `Candidate answer:\n${answer}`, + 'Ask the next question.', + ].join('\n\n'), + ) + + if (!result.success) { + console.error('Interviewer failed:', result.output) + process.exit(1) + } + + answer = await readlineFromCandidate(result.output, rl) + if (isExitAnswer(answer)) { + console.log('\nInterview stopped by candidate.') + break + } + + turnsCompleted++ + await mem.write('interviewer', `turn-${turn}`, `Q: ${result.output}\nA: ${answer}`) + + const observerResult = await observer.run( + [ + 'Review transcript and candidate materials. Write flags for the next turn.', + await mem.getSummary(), + ].join('\n\n'), + ) + + if (!observerResult.success) { + console.error('Observer failed:', observerResult.output) + process.exit(1) + } + + await mem.write('observer', 'flags', observerResult.output) + } +} finally { + rl.close() +} + +// --------------------------------------------------------------------------- +// Structured debrief +// --------------------------------------------------------------------------- + +console.log('\n' + '='.repeat(60)) +console.log('DEBRIEF') +console.log('='.repeat(60)) + +const debriefResult = await reporter.run(`Summarize the full interview:\n\n${await mem.getSummary()}`) + +if (!debriefResult.success || !debriefResult.structured) { + console.error('Debrief generation failed:', debriefResult.output) + process.exit(1) +} + +const debrief = debriefResult.structured as Debrief + +console.log(JSON.stringify(debrief, null, 2)) +console.log() +console.log(`Turns completed: ${turnsCompleted}`) +console.log('Done.') diff --git a/examples/fixtures/interview-candidate/code.ts b/examples/fixtures/interview-candidate/code.ts new file mode 100644 index 0000000..0b89afe --- /dev/null +++ b/examples/fixtures/interview-candidate/code.ts @@ -0,0 +1,46 @@ +import type { Request, Response } from 'express' +import { pool } from './db.js' + +export async function approveExpense(req: Request, res: Response): Promise { + const expenseId = req.params.id + const approverId = req.body.approverId + + if (!expenseId || !approverId) { + res.status(400).json({ error: 'missing fields' }) + return + } + + const client = await pool.connect() + try { + await client.query('BEGIN') + + const expense = await client.query( + 'SELECT id, status, amount_cents FROM expenses WHERE id = $1', + [expenseId], + ) + + if (expense.rowCount === 0) { + await client.query('ROLLBACK') + res.status(404).json({ error: 'expense not found' }) + return + } + + await client.query( + 'UPDATE expenses SET status = $1, approved_by = $2, approved_at = NOW() WHERE id = $3', + ['approved', approverId, expenseId], + ) + + await client.query( + 'INSERT INTO audit_log(entity_id, action, actor_id) VALUES ($1, $2, $3)', + [expenseId, 'expense_approved', approverId], + ) + + await client.query('COMMIT') + res.status(200).json({ ok: true }) + } catch (error) { + await client.query('ROLLBACK') + res.status(500).json({ error: 'internal error' }) + } finally { + client.release() + } +} diff --git a/examples/fixtures/interview-candidate/job-description.md b/examples/fixtures/interview-candidate/job-description.md new file mode 100644 index 0000000..13e90ad --- /dev/null +++ b/examples/fixtures/interview-candidate/job-description.md @@ -0,0 +1,15 @@ +# Target Job Spec + +Role: Backend Engineer (Payments and Workflow Systems) + +Expectations: +- Strong fundamentals in API design, database transactions, and failure handling +- Able to reason about idempotency, retries, concurrency, and data consistency +- Comfortable explaining tradeoffs between implementation simplicity and operational robustness +- Can debug production issues using logs, metrics, and traces + +Interview focus: +- project ownership and decision making +- correctness under retries and duplicated external events +- transaction boundaries and race conditions +- scaling path from in-process jobs to durable queues diff --git a/examples/fixtures/interview-candidate/project-notes.md b/examples/fixtures/interview-candidate/project-notes.md new file mode 100644 index 0000000..d3d734c --- /dev/null +++ b/examples/fixtures/interview-candidate/project-notes.md @@ -0,0 +1,15 @@ +# Project Notes + +Current showcase project: +- Team expense management platform for small businesses +- Backend written in TypeScript with Express and PostgreSQL +- Background jobs process receipt OCR results and reimbursement approvals + +Implementation details worth probing: +- Uses Redis for short-lived deduplication keys on webhook ingestion +- Exposes `/expenses/:id/approve` for manager approval actions +- Runs nightly reconciliation jobs against third-party accounting exports + +Known tradeoffs: +- No queue system yet; background work is triggered in-process +- Approval flow is eventually consistent during third-party sync windows diff --git a/examples/fixtures/interview-candidate/resume.md b/examples/fixtures/interview-candidate/resume.md new file mode 100644 index 0000000..20ad4f1 --- /dev/null +++ b/examples/fixtures/interview-candidate/resume.md @@ -0,0 +1,13 @@ +# Candidate Resume + +Name: Maya Lin + +Summary: +- 2 years of backend-focused software engineering experience +- Primary stack: TypeScript, Node.js, PostgreSQL, Redis +- Comfortable with Docker, CI pipelines, and REST API design + +Selected claims: +- Led the redesign of an internal notification service that reduced alert latency by 40% +- Built idempotent payment retry logic for failed checkout webhooks +- Improved production incident response by adding structured logging and tracing diff --git a/tests/personalized-interview-simulator.test.ts b/tests/personalized-interview-simulator.test.ts new file mode 100644 index 0000000..a4cbce6 --- /dev/null +++ b/tests/personalized-interview-simulator.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest' +import { z } from 'zod' +import { Agent } from '../src/agent/agent.js' +import { AgentRunner } from '../src/agent/runner.js' +import { SharedMemory } from '../src/memory/shared.js' +import { ToolExecutor } from '../src/tool/executor.js' +import { ToolRegistry } from '../src/tool/framework.js' +import type { AgentConfig, LLMAdapter, LLMResponse } from '../src/types.js' + +const DebriefSchema = z.object({ + questions_asked: z.array( + z.object({ + question: z.string(), + why_it_mattered: z.string(), + }), + ), + weak_spots: z.array(z.string()), + strong_spots: z.array(z.string()), + overall_assessment: z.object({ + recommendation: z.enum(['strong-hire', 'hire', 'lean-hire', 'lean-no-hire', 'no-hire']), + summary: z.string(), + }), +}) + +function mockAdapter(responses: string[]): LLMAdapter { + let callIndex = 0 + return { + name: 'mock', + async chat() { + const text = responses[callIndex++] ?? '' + return { + id: `mock-${callIndex}`, + content: [{ type: 'text' as const, text }], + model: 'mock-model', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 20 }, + } satisfies LLMResponse + }, + async *stream() { + /* unused */ + }, + } +} + +function buildMockAgent(config: AgentConfig, responses: string[]): Agent { + const adapter = mockAdapter(responses) + const registry = new ToolRegistry() + const executor = new ToolExecutor(registry) + const agent = new Agent(config, registry, executor) + const runner = new AgentRunner(adapter, registry, executor, { + model: config.model, + systemPrompt: config.systemPrompt, + maxTurns: config.maxTurns, + maxTokens: config.maxTokens, + temperature: config.temperature, + agentName: config.name, + }) + ;(agent as any).runner = runner + return agent +} + +describe('personalized interview simulator architecture', () => { + it('uses shared memory between interviewer turns and ends with structured debrief', async () => { + const mem = new SharedMemory() + + await mem.write('candidate', 'resume', 'Built idempotent payment retry logic.') + await mem.write('candidate', 'project-notes', 'Uses Redis keys to deduplicate webhook events.') + await mem.write('candidate', 'code', 'POST /webhooks/stripe retries failed events.') + await mem.write('role', 'target-job-spec', 'Test idempotency, transactions, and failure handling.') + + const interviewer = buildMockAgent( + { + name: 'interviewer', + model: 'mock-model', + systemPrompt: 'Ask one probing question at a time.', + maxTurns: 2, + }, + [ + 'You mentioned idempotent retry logic. How did you prevent duplicate payment state transitions?', + 'Walk me through the transaction boundary you chose when a webhook retry arrives mid-update.', + ], + ) + + const observer = buildMockAgent( + { + name: 'observer', + model: 'mock-model', + systemPrompt: 'Write compact observer flags.', + maxTurns: 1, + }, + [ + '- Candidate named idempotency keys but did not explain expiry choice.\n- Concurrency handling not yet tested.', + '- Transaction boundary explanation was good.\n- Still probe race conditions around audit logging.', + ], + ) + + const debriefPayload = { + questions_asked: [ + { + question: 'How did you prevent duplicate payment state transitions?', + why_it_mattered: 'The role requires strong reasoning about idempotency under retries.', + }, + { + question: 'What transaction boundary did you choose during webhook retries?', + why_it_mattered: 'The role expects correctness under concurrent updates.', + }, + ], + weak_spots: [ + 'Did not justify idempotency key expiry tradeoff.', + ], + strong_spots: [ + 'Explained transaction boundaries clearly.', + ], + overall_assessment: { + recommendation: 'lean-hire', + summary: 'Good backend fundamentals with one notable gap around retry-window tradeoffs.', + }, + } + + const reporter = buildMockAgent( + { + name: 'reporter', + model: 'mock-model', + systemPrompt: 'Return a structured debrief.', + maxTurns: 2, + outputSchema: DebriefSchema, + }, + [JSON.stringify(debriefPayload)], + ) + + let answer = '' + for (let turn = 0; turn < 2; turn++) { + const ctx = await mem.getSummary() + const prompt = turn === 0 + ? `Context:\n${ctx}\n\nAsk the most probing opening question you can justify from the materials and role.` + : `Context:\n${ctx}\n\nCandidate answer:\n${answer}\n\nAsk the next question.` + + const questionResult = await interviewer.prompt(prompt) + expect(questionResult.success).toBe(true) + + answer = turn === 0 + ? 'We used a Redis idempotency key keyed by provider event id before mutating payment state.' + : 'We wrapped the expense update and audit write in one database transaction.' + + await mem.write('interviewer', `turn-${turn}`, `Q: ${questionResult.output}\nA: ${answer}`) + + const observerResult = await observer.run( + `Review transcript and candidate materials; write flags for the next turn:\n${await mem.getSummary()}`, + ) + + expect(observerResult.success).toBe(true) + await mem.write('observer', 'flags', observerResult.output) + } + + const summary = await mem.getSummary() + expect(summary).toContain('candidate') + expect(summary).toContain('role') + expect(summary).toContain('interviewer') + expect(summary).toContain('observer') + expect(summary).toContain('Still probe race conditions around audit logging') + + const debrief = await reporter.run(`Summarize the full interview:\n${summary}`) + expect(debrief.success).toBe(true) + expect(debrief.structured).toBeDefined() + expect(DebriefSchema.parse(debrief.structured)).toEqual(debriefPayload) + + const history = interviewer.getHistory() + expect(history.length).toBe(4) + }) +})