branch:
server.ts
15154 bytesRaw
/**
* Sub-Agents Example — Multi-Perspective Analysis
*
* The coordinator receives a question from the user, then spawns three
* PerspectiveAgent facets in parallel. Each facet independently calls the
* LLM with its own role/persona and produces an analysis. The coordinator
* waits for all three, then synthesizes them into a final response.
*
* Each PerspectiveAgent is a facet — a child DurableObject with its own
* isolated SQLite. It persists its analysis history independently. The
* coordinator can abort any slow facet without affecting the others.
*
* ┌─────────── CoordinatorAgent ──────────────────────────────────┐
* │ │
* │ User question ──▶ analyze() ──┬──▶ subAgent("technical") │
* │ ├──▶ subAgent("business") │
* │ └──▶ subAgent("skeptic") │
* │ │
* │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
* │ │ Technical │ │ Business │ │ Skeptic │ │
* │ │ Expert │ │ Analyst │ │ (Devil's │ │
* │ │ (facet) │ │ (facet) │ │ Advocate) │ │
* │ │ │ │ │ │ (facet) │ │
* │ │ own SQLite │ │ own SQLite │ │ own SQLite │ │
* │ │ own LLM call │ │ own LLM call │ │ own LLM call │ │
* │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
* │ │ │ │ │
* │ └────────────────┼────────────────┘ │
* │ ▼ │
* │ synthesize() │
* │ │ │
* │ ▼ │
* │ Final response │
* └────────────────────────────────────────────────────────────────┘
*/
import { createWorkersAI } from "workers-ai-provider";
import { Agent, routeAgentRequest, callable } from "agents";
import { AIChatAgent } from "@cloudflare/ai-chat";
import {
generateText,
streamText,
convertToModelMessages,
tool,
stepCountIs
} from "ai";
import { z } from "zod";
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
/** The three perspectives used for analysis. */
export const PERSPECTIVES = {
technical: {
name: "Technical Expert",
icon: "gear",
system:
"You are a senior technical expert. Analyze the question from a purely " +
"technical standpoint: feasibility, architecture, performance, scalability, " +
"security implications. Be specific and cite concrete technical concerns. " +
"Keep your response to 2-3 focused paragraphs."
},
business: {
name: "Business Analyst",
icon: "chart",
system:
"You are a sharp business analyst. Analyze the question from a business " +
"perspective: market impact, cost/benefit, competitive advantage, risk, " +
"timeline, and ROI. Be pragmatic and numbers-oriented where possible. " +
"Keep your response to 2-3 focused paragraphs."
},
skeptic: {
name: "Devil's Advocate",
icon: "warning",
system:
"You are a constructive devil's advocate. Challenge the premise of the " +
"question. What could go wrong? What are the hidden assumptions? What " +
"alternatives haven't been considered? Be provocative but fair. " +
"Keep your response to 2-3 focused paragraphs."
}
} as const;
export type PerspectiveId = keyof typeof PERSPECTIVES;
export type PerspectiveResult = {
perspectiveId: PerspectiveId;
name: string;
analysis: string;
timestamp: string;
};
export type AnalysisRound = {
id: string;
question: string;
perspectives: PerspectiveResult[];
synthesis: string | null;
timestamp: string;
};
export type SubagentState = {
analyses: AnalysisRound[];
};
// ─────────────────────────────────────────────────────────────────────────────
// PerspectiveAgent — sub-agent that independently calls the LLM
//
// Each instance has its own role (system prompt), its own SQLite for
// persisting past analyses, and makes its own LLM calls. The coordinator
// cannot see the sub-agent's internal state — it only gets back the
// analysis text through the analyze() RPC method.
// ─────────────────────────────────────────────────────────────────────────────
export class PerspectiveAgent extends Agent<Env> {
onStart() {
this.sql`
CREATE TABLE IF NOT EXISTS analyses (
id TEXT PRIMARY KEY,
question TEXT NOT NULL,
analysis TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
)
`;
}
/**
* Analyze a question from this perspective. Calls the LLM independently
* with this perspective's system prompt. Stores the result in the
* sub-agent's own SQLite.
*
* The coordinator calls this on each sub-agent in parallel — three LLM
* calls running concurrently, each in its own isolated context.
*/
async analyze(perspectiveId: string, question: string): Promise<string> {
const perspective =
PERSPECTIVES[perspectiveId as PerspectiveId] ?? PERSPECTIVES.technical;
const workersai = createWorkersAI({ binding: this.env.AI });
const result = await generateText({
model: workersai("@cf/moonshotai/kimi-k2.5", {
sessionAffinity: this.sessionAffinity
}),
system: perspective.system,
prompt: question
});
const id = crypto.randomUUID();
this.sql`
INSERT INTO analyses (id, question, analysis)
VALUES (${id}, ${question}, ${result.text})
`;
return result.text;
}
/** Return past analyses from this sub-agent's storage. */
getHistory(): { question: string; analysis: string; timestamp: string }[] {
return this.sql<{ question: string; analysis: string; timestamp: string }>`
SELECT question, analysis, timestamp
FROM analyses ORDER BY timestamp DESC LIMIT 10
`;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// CoordinatorAgent — orchestrates the sub-agents
// ─────────────────────────────────────────────────────────────────────────────
export class CoordinatorAgent extends AIChatAgent<Env, SubagentState> {
initialState: SubagentState = {
analyses: []
};
async onStart() {
this._initTables();
this._syncState();
}
// ─── Storage ─────────────────────────────────────────────────────────
private _initTables() {
this.sql`
CREATE TABLE IF NOT EXISTS analysis_rounds (
id TEXT PRIMARY KEY,
question TEXT NOT NULL,
synthesis TEXT,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
)
`;
this.sql`
CREATE TABLE IF NOT EXISTS perspective_results (
id TEXT PRIMARY KEY,
round_id TEXT NOT NULL,
perspective_id TEXT NOT NULL,
name TEXT NOT NULL,
analysis TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
)
`;
}
private _syncState() {
const rounds = this.sql<{
id: string;
question: string;
synthesis: string | null;
timestamp: string;
}>`
SELECT id, question, synthesis, timestamp
FROM analysis_rounds ORDER BY timestamp DESC LIMIT 10
`;
const analyses: AnalysisRound[] = rounds.map((round) => {
const perspectives = this.sql<PerspectiveResult>`
SELECT perspective_id as perspectiveId, name, analysis, timestamp
FROM perspective_results
WHERE round_id = ${round.id}
ORDER BY perspective_id
`;
return { ...round, perspectives };
});
this.setState({ analyses });
}
// ─── Multi-perspective analysis ──────────────────────────────────────
/**
* Run all three perspective agents in parallel, then synthesize.
*
* This is the core pattern: fan out to facets, fan in the results.
* Each facet makes its own independent LLM call with its own context.
*/
@callable()
async analyzeQuestion(question: string): Promise<AnalysisRound> {
const roundId = crypto.randomUUID();
// Store the round
this.sql`
INSERT INTO analysis_rounds (id, question) VALUES (${roundId}, ${question})
`;
this._syncState();
// Fan out: call all three sub-agents in parallel
const perspectiveIds: PerspectiveId[] = [
"technical",
"business",
"skeptic"
];
const results = await Promise.all(
perspectiveIds.map(async (pid) => {
const agent = await this.subAgent(PerspectiveAgent, pid);
const analysis = await agent.analyze(pid, question);
const perspective = PERSPECTIVES[pid];
// Store each result in the coordinator's own storage
const resultId = crypto.randomUUID();
this.sql`
INSERT INTO perspective_results (id, round_id, perspective_id, name, analysis)
VALUES (${resultId}, ${roundId}, ${pid}, ${perspective.name}, ${analysis})
`;
this._syncState();
return {
perspectiveId: pid,
name: perspective.name,
analysis,
timestamp: new Date().toISOString()
};
})
);
// Synthesize: ask the LLM to combine the three perspectives
const workersai = createWorkersAI({ binding: this.env.AI });
const synthesisResult = await generateText({
model: workersai("@cf/moonshotai/kimi-k2.5", {
sessionAffinity: this.sessionAffinity
}),
system:
"You are a senior advisor synthesizing multiple perspectives into a " +
"balanced, actionable recommendation. Be concise — 2-3 paragraphs max.",
prompt:
`Question: ${question}\n\n` +
results.map((r) => `## ${r.name}\n${r.analysis}`).join("\n\n") +
"\n\nSynthesize these three perspectives into a balanced recommendation."
});
// Store synthesis
this.sql`
UPDATE analysis_rounds SET synthesis = ${synthesisResult.text}
WHERE id = ${roundId}
`;
this._syncState();
return {
id: roundId,
question,
perspectives: results,
synthesis: synthesisResult.text,
timestamp: new Date().toISOString()
};
}
// ─── Chat ────────────────────────────────────────────────────────────
async onChatMessage() {
const workersai = createWorkersAI({ binding: this.env.AI });
const agent = this;
const result = streamText({
model: workersai("@cf/moonshotai/kimi-k2.5", {
sessionAffinity: this.sessionAffinity
}),
system: `You are a coordinator that manages three specialist sub-agents to analyze questions from multiple perspectives.
When the user asks a question or presents a topic for analysis, use the analyzeFromAllPerspectives tool. This will:
1. Send the question to three independent sub-agents (Technical Expert, Business Analyst, Devil's Advocate)
2. Each sub-agent independently calls an LLM with its own specialized system prompt
3. All three run in parallel, each in its own isolated context
4. A synthesis combines all three perspectives
After receiving the analysis, present a brief summary to the user highlighting key points from each perspective and the synthesis.
For simple conversation (greetings, follow-up questions about results), respond directly without invoking the tool.`,
messages: await convertToModelMessages(this.messages),
tools: {
analyzeFromAllPerspectives: tool({
description:
"Analyze a question from three perspectives in parallel: " +
"Technical Expert, Business Analyst, and Devil's Advocate. " +
"Each perspective agent runs independently with its own context. " +
"Returns all three analyses plus a synthesis.",
inputSchema: z.object({
question: z.string().describe("The question or topic to analyze")
}),
execute: async ({ question }) => {
const round = await agent.analyzeQuestion(question);
return {
question: round.question,
perspectives: round.perspectives.map((p) => ({
role: p.name,
analysis: p.analysis
})),
synthesis: round.synthesis
};
}
})
},
stopWhen: stepCountIs(3)
});
return result.toUIMessageStreamResponse();
}
}
// ─────────────────────────────────────────────────────────────────────────────
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
}
} satisfies ExportedHandler<Env>;