The Atlas BigLaw / Big Michael — documentation bound to its code
7 documents

Inside a DyTopo round

Open one round of Dynamic Topology Routing: agents declare what they Need and Offer, cosine similarity wires them into a comm graph, jurisdiction-ineligible agents are dropped, then everyone runs an agentic loop whose findings are gated, debated, verified, and rolled up into memory.

src/agents/base.ts505 lines · Agent L64–318
Outline 14 symbols
1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Discover Legal
3// This program is free software: you can redistribute it and/or modify it
4// under the terms of the GNU Affero General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version. See <https://www.gnu.org/licenses/>.
7
8import { v4 as uuidv4 } from "uuid";
9import { logger } from "../logger.js";
10import { Config } from "../config.js";
11import { selectModel, estimateComplexity, modelLabel } from "../routing/model.js";
12import { getProvider, resolveModelId, isOllamaModel, isLocalModel } from "../providers/index.js";
13import { costStore, calcCostUsd, calcWattHours } from "../cost/index.js";
14import type { CostContext } from "../cost/index.js";
15import type { ProviderMessage, ProviderToolResultBlock } from "../providers/index.js";
16import type { ToolRegistry, ToolContext } from "../tools/index.js";
17import type { KnowledgeStore } from "../knowledge/index.js";
18import { sanitizePromptContent } from "../adapters/lavern.js";
19import type { InterRoundMemoryStore } from "../memory/index.js";
20import type { TimeStore } from "../time/index.js";
21import type {
22 AgentDefinition,
23 AgentMessage,
24 Finding,
25 Citation,
26 NeedDescriptor,
27 OfferDescriptor,
28 RoundGoal,
29 MemoryEntry,
30 ToneProfile,
31} from "../types.js";
32import { auditLogger, ACTOR_SYSTEM } from "../audit/index.js";
33
34export interface AgentContext {
35 roundGoal: RoundGoal;
36 /** Messages routed to this agent via the DyTopo communication graph */
37 incomingMessages: AgentMessage[];
38 /** Inter-round memory entries retrieved for this agent */
39 memoryEntries: MemoryEntry[];
40 /** Task description for grounding */
41 taskDescription: string;
42 /** Task ID — required for tool context */
43 taskId?: string;
44 /** Tool registry — when provided, agent runs the full tool_use agentic loop */
45 toolRegistry?: ToolRegistry;
46 /** Knowledge store reference forwarded to tool context */
47 knowledge?: KnowledgeStore;
48 /** Memory store reference forwarded to tool context */
49 memory?: InterRoundMemoryStore;
50 /** Document owner scope — undefined means partner (see all), set for lawyer-submitted tasks */
51 ownerId?: string;
52 /** Tone fingerprint of the assigned lawyer — injected into drafting-domain agent prompts. */
53 assignedLawyerTone?: ToneProfile;
54 // ── Agent billing context ─────────────────────────────────────────────────
55 /** Time store to record agent work entries. When absent, no entry is created. */
56 timeStore?: TimeStore;
57 /** Responsible lawyer ID — agent entry is attributed to them for billing. */
58 responsibleLawyerId?: string;
59 responsibleLawyerName?: string;
60 matterNumber?: string;
61 clientNumber?: string;
62}
63
64export class Agent {
65 readonly definition: AgentDefinition;
66
67 constructor(definition: AgentDefinition) {
68 this.definition = definition;
69 }
70
71 /**
72 * Generate Need/Offer descriptors — always uses Haiku (lightweight, per-round, many calls).
73 */
74 async generateNeedOffer(ctx: AgentContext): Promise<{
75 need: NeedDescriptor;
76 offer: OfferDescriptor;
77 }> {
78 const model = selectModel({
79 tier: this.definition.tier,
80 type: this.definition.type,
81 taskType: "descriptor", // always Haiku
82 });
83 const prompt = buildNeedOfferPrompt(this.definition, ctx);
84 const response = await this.callModel(prompt, 200, model, { taskId: ctx.taskId, costContext: "descriptor" });
85 return parseNeedOffer(response, this.definition.id);
86 }
87
88 /**
89 * Process round context and produce findings.
90 * When toolRegistry + knowledge + memory are present, runs the full Anthropic
91 * tool_use agentic loop — calling tools as needed until stop_reason === "end_turn".
92 * Falls back to a single-shot call when tools are not wired up.
93 */
94 async process(ctx: AgentContext): Promise<Finding[]> {
95 const startedAt = new Date();
96
97 if (ctx.taskId) {
98 auditLogger.write({
99 event: "agent.processing",
100 actorId: ctx.responsibleLawyerId ?? ACTOR_SYSTEM,
101 taskId: ctx.taskId,
102 agentId: this.definition.id,
103 data: { agentName: this.definition.name, tier: this.definition.tier, domain: this.definition.domain, round: ctx.roundGoal.round },
104 });
105 }
106
107 const taskType = inferTaskType(this.definition);
108 const complexity = estimateComplexity(ctx.roundGoal.description);
109
110 const model = selectModel({
111 tier: this.definition.tier,
112 type: this.definition.type,
113 taskType,
114 complexity,
115 });
116
117 const prompt = buildProcessingPrompt(this.definition, ctx);
118 const maxTokens = this.definition.tier === 3 ? 600 : this.definition.tier === 0 ? 4000 : 2500;
119
120 logger.debug("Agent processing", {
121 agent: this.definition.name,
122 model: modelLabel(model),
123 taskType,
124 complexity,
125 tools: this.definition.allowedTools.length,
126 });
127
128 const hasTools =
129 ctx.toolRegistry !== undefined &&
130 ctx.knowledge !== undefined &&
131 ctx.memory !== undefined &&
132 ctx.taskId !== undefined &&
133 this.definition.allowedTools.length > 0;
134
135 const text = hasTools
136 ? await this.runAgenticLoop(prompt, maxTokens, model, {
137 toolRegistry: ctx.toolRegistry!,
138 knowledge: ctx.knowledge!,
139 memory: ctx.memory!,
140 taskId: ctx.taskId!,
141 ownerId: ctx.ownerId,
142 responsibleLawyerId: ctx.responsibleLawyerId,
143 })
144 : await this.callModel(prompt, maxTokens, model, { taskId: ctx.taskId, costContext: "task" });
145
146 const findings = parseFindings(text, this.definition);
147
148 if (ctx.taskId) {
149 for (const f of findings) {
150 auditLogger.write({
151 event: "finding.produced",
152 actorId: ctx.responsibleLawyerId ?? ACTOR_SYSTEM,
153 taskId: ctx.taskId,
154 agentId: this.definition.id,
155 data: { findingId: f.id, agentName: this.definition.name, confidence: f.confidence, round: ctx.roundGoal.round, contentPreview: f.content.slice(0, 150) },
156 });
157 }
158 auditLogger.write({
159 event: "agent.complete",
160 actorId: ctx.responsibleLawyerId ?? ACTOR_SYSTEM,
161 taskId: ctx.taskId,
162 agentId: this.definition.id,
163 durationMs: Date.now() - startedAt.getTime(),
164 data: { agentName: this.definition.name, findingCount: findings.length, round: ctx.roundGoal.round },
165 });
166 }
167
168 // Record agent time entry if billing is configured and a task is in context.
169 if (Config.agentBilling.enabled && ctx.timeStore && ctx.taskId) {
170 const rate = resolveAgentBillingRate(this.definition);
171 if (rate > 0) {
172 const entry = ctx.timeStore.open({
173 agentId: this.definition.id,
174 agentName: this.definition.name,
175 profileId: ctx.responsibleLawyerId,
176 profileName: ctx.responsibleLawyerName,
177 taskId: ctx.taskId,
178 matterNumber: ctx.matterNumber,
179 clientNumber: ctx.clientNumber,
180 description: `[AI] ${this.definition.name}: ${ctx.roundGoal.description.slice(0, 200)}`,
181 event: "agent_work",
182 billingRate: rate,
183 startedAt,
184 });
185 ctx.timeStore.close(entry.id);
186 }
187 }
188
189 return findings;
190 }
191
192 /**
193 * Provider-agnostic tool_use agentic loop.
194 * Works with both Anthropic and Ollama (via the provider abstraction).
195 * Loops until stop_reason === "end_turn" or the 10-iteration safety cap is hit.
196 */
197 private async runAgenticLoop(
198 initialPrompt: string,
199 maxTokens: number,
200 model: string,
201 refs: {
202 toolRegistry: ToolRegistry;
203 knowledge: KnowledgeStore;
204 memory: InterRoundMemoryStore;
205 taskId: string;
206 ownerId?: string;
207 responsibleLawyerId?: string;
208 },
209 ): Promise<string> {
210 const toolSchemas = refs.toolRegistry.schemasFor(this.definition.allowedTools);
211 const toolCtx: ToolContext = {
212 knowledge: refs.knowledge,
213 memory: refs.memory,
214 taskId: refs.taskId,
215 ownerId: refs.ownerId,
216 responsibleLawyerId: refs.responsibleLawyerId,
217 };
218
219 const provider = getProvider(model);
220 const bareModel = resolveModelId(model);
221 const messages: ProviderMessage[] = [{ role: "user", content: initialPrompt }];
222 let finalText = "";
223
224 for (let iteration = 0; iteration < Config.agents.maxToolIterations; iteration++) {
225 const response = await provider.chat({
226 model: bareModel,
227 maxTokens,
228 system: this.definition.systemPrompt,
229 tools: toolSchemas,
230 messages,
231 cacheSystem: true,
232 });
233
234 recordCost(response, model, "task", { taskId: refs.taskId, agentId: this.definition.id });
235
236 // Capture the latest text block as the candidate final response
237 for (const block of response.content) {
238 if (block.type === "text") finalText = block.text;
239 }
240
241 if (response.stopReason === "end_turn") break;
242
243 if (response.stopReason === "tool_use") {
244 // Append full assistant turn (may contain text + tool_use blocks)
245 messages.push({ role: "assistant", content: response.content });
246
247 // Execute every tool_use block and collect results
248 const toolResults: ProviderToolResultBlock[] = [];
249 for (const block of response.content) {
250 if (block.type !== "tool_use") continue;
251
252 logger.debug("Agent tool call", {
253 agent: this.definition.name,
254 tool: block.name,
255 provider: modelLabel(model),
256 });
257
258 let result: unknown;
259 // Enforce capability-based access: only tools in the agent's allowedTools
260 // list may be called, regardless of what the LLM requests.
261 if (!this.definition.allowedTools.includes(block.name)) {
262 result = { error: `Tool '${block.name}' is not permitted for this agent` };
263 } else {
264 try {
265 result = await refs.toolRegistry.execute(block.name, block.input, toolCtx);
266 } catch (err) {
267 result = { error: (err as Error).message };
268 }
269 }
270
271 // Cap each tool result to 100 KB before inserting into the message
272 // history to prevent a large result from exhausting the context window.
273 const raw = JSON.stringify(result);
274 const content = raw.length > 100_000 ? raw.slice(0, 100_000) + "…[truncated]" : raw;
275 toolResults.push({
276 type: "tool_result",
277 tool_use_id: block.id,
278 content,
279 });
280 }
281
282 messages.push({ role: "user", content: toolResults });
283 continue;
284 }
285
286 logger.warn("Agentic loop unexpected stop_reason", {
287 agent: this.definition.name,
288 stop_reason: response.stopReason,
289 iteration,
290 });
291 break;
292 }
293
294 return finalText;
295 }
296
297 private async callModel(
298 userMessage: string,
299 maxTokens: number,
300 model: string,
301 meta?: { taskId?: string; costContext?: CostContext },
302 ): Promise<string> {
303 const provider = getProvider(model);
304 const response = await provider.chat({
305 model: resolveModelId(model),
306 maxTokens,
307 system: this.definition.systemPrompt,
308 messages: [{ role: "user", content: userMessage }],
309 cacheSystem: true,
310 });
311 recordCost(response, model, meta?.costContext ?? "task", { taskId: meta?.taskId, agentId: this.definition.id });
312 const textBlock = response.content.find((b) => b.type === "text");
313 if (!textBlock || textBlock.type !== "text") {
314 throw new Error(`No text in response from model ${model}`);
315 }
316 return textBlock.text;
317 }
318}
319
320// ─── Cost recording helper ────────────────────────────────────────────────────
321
322function recordCost(
323 response: import("../providers/index.js").ChatResponse,
324 modelId: string,
325 context: CostContext,
326 meta: { taskId?: string; agentId?: string; profileId?: string },
327): void {
328 const isLocal = isOllamaModel(modelId) || isLocalModel(modelId);
329 const bareModel = resolveModelId(modelId);
330 const cw = response.usage.cacheWriteTokens ?? 0;
331 const cr = response.usage.cacheReadTokens ?? 0;
332 costStore.record({
333 model: bareModel,
334 provider: isLocal ? (isOllamaModel(modelId) ? "ollama" : "local") : "anthropic",
335 inputTokens: response.usage.inputTokens,
336 outputTokens: response.usage.outputTokens,
337 ...(cw ? { cacheWriteTokens: cw } : {}),
338 ...(cr ? { cacheReadTokens: cr } : {}),
339 costUsd: isLocal ? null : calcCostUsd(bareModel, response.usage.inputTokens, response.usage.outputTokens, cw, cr),
340 estimatedWh: isLocal ? calcWattHours(Config.local.inferenceWatts, response.durationMs) : null,
341 estimatedWatts: isLocal ? Config.local.inferenceWatts : null,
342 durationMs: response.durationMs,
343 context,
344 ...meta,
345 });
346}
347
348// ─── Agent billing rate resolution ────────────────────────────────────────────
349
350function resolveAgentBillingRate(def: AgentDefinition): number {
351 if (def.billingRate !== undefined) return def.billingRate;
352 const tierRates: Record<number, number> = {
353 0: Config.agentBilling.rateT0,
354 1: Config.agentBilling.rateT1,
355 2: Config.agentBilling.rateT2,
356 3: Config.agentBilling.rateT3,
357 };
358 return tierRates[def.tier] ?? Config.agentBilling.defaultRate;
359}
360
361// ─── Task type inference ──────────────────────────────────────────────────────
362
363function inferTaskType(def: AgentDefinition): import("../routing/model.js").TaskType {
364 if (def.tier === 3) return "extraction";
365 if (def.id.includes("drafter") || def.id.includes("writer")) return "drafting";
366 if (def.id.includes("analyst") || def.id.includes("agent")) return "reasoning";
367 if (def.type === "root") return "synthesis";
368 if (def.type === "manager") return "routing";
369 return "reasoning";
370}
371
372// ─── Prompt builders ──────────────────────────────────────────────────────────
373
374function buildNeedOfferPrompt(def: AgentDefinition, ctx: AgentContext): string {
375 // sanitizePromptContent prevents user-supplied strings from injecting
376 // structural FINDING / END_FINDING markers into the prompt.
377 const taskDesc = sanitizePromptContent(ctx.taskDescription);
378 const memoryLines = ctx.memoryEntries.length
379 ? ctx.memoryEntries.map((e) => `[Round ${e.round}] ${sanitizePromptContent(e.content)}`).join("\n")
380 : "None yet.";
381
382 return `TASK: ${taskDesc}
383
384CURRENT ROUND GOAL (Round ${ctx.roundGoal.round}, Phase: ${ctx.roundGoal.phase}):
385${sanitizePromptContent(ctx.roundGoal.description)}
386
387YOUR ROLE: ${def.name}${def.description}
388
389RELEVANT MEMORY FROM PRIOR ROUNDS:
390${memoryLines}
391
392Output exactly:
393NEED: <one sentence — what information or expertise you currently need from other agents>
394OFFER: <one sentence — what you can contribute this round given your role>`;
395}
396
397function buildProcessingPrompt(def: AgentDefinition, ctx: AgentContext): string {
398 const taskDesc = sanitizePromptContent(ctx.taskDescription);
399
400 const incoming = ctx.incomingMessages.length
401 ? ctx.incomingMessages
402 .map((m) => `[FROM: ${m.from}]\n${sanitizePromptContent(m.content)}`)
403 .join("\n\n---\n\n")
404 : "No messages routed to you this round.";
405
406 // Memory content originates from prior agent outputs stored in the RuVector memory store.
407 // An attacker who can influence task content could craft memory entries
408 // containing fake FINDING markers. Sanitise before interpolation.
409 const memory = ctx.memoryEntries.length
410 ? ctx.memoryEntries.map((e) => `[Round ${e.round}${e.phase}] ${sanitizePromptContent(e.content)}`).join("\n")
411 : "No prior memory.";
412
413 const toneBlock =
414 def.domain === "drafting" && ctx.assignedLawyerTone
415 ? `\n────────────────────────────────────────────────────────────────\nASSIGNED LAWYER TONE PROFILE — mirror this voice in all drafted output:\n${sanitizePromptContent(ctx.assignedLawyerTone.injectionSnippet)}\n`
416 : "";
417
418 return `TASK: ${taskDesc}
419
420ROUND GOAL (Round ${ctx.roundGoal.round} — Phase: ${ctx.roundGoal.phase}):
421${sanitizePromptContent(ctx.roundGoal.description)}
422
423EXPECTED OUTPUTS THIS ROUND:
424${ctx.roundGoal.expectedOutputs.map((o, i) => `${i + 1}. ${sanitizePromptContent(o)}`).join("\n")}
425
426INTER-ROUND MEMORY (what has been established in prior rounds):
427${memory}
428
429MESSAGES ROUTED TO YOU THIS ROUND (from other agents whose offers matched your needs):
430${incoming}
431${toneBlock}
432────────────────────────────────────────────────────────────────
433Produce your findings. For each distinct finding:
434
435FINDING:
436Content: <finding — state your conclusion or analysis clearly>
437Citation: SOURCE=<document ID or URL or case ECLI> | QUOTE=<verbatim text> | PAGE=<page/para if known>
438Confidence: <0.0–1.0>
439END_FINDING
440
441Rules:
442- Each finding must have at least one Citation.
443- Quote must be verbatim — not paraphrased.
444- Multiple Citations allowed per finding (repeat Citation: lines).
445- If you have no findings this round: NO_FINDINGS`;
446}
447
448// ─── Response parsers ─────────────────────────────────────────────────────────
449
450function parseNeedOffer(
451 text: string,
452 agentId: string,
453): { need: NeedDescriptor; offer: OfferDescriptor } {
454 const needMatch = text.match(/NEED:\s*(.+)/i);
455 const offerMatch = text.match(/OFFER:\s*(.+)/i);
456 return {
457 need: { agentId, text: (needMatch?.[1]?.trim() ?? "No specific need this round.").slice(0, 500) },
458 offer: { agentId, text: (offerMatch?.[1]?.trim() ?? "General domain expertise available.").slice(0, 500) },
459 };
460}
461
462function parseFindings(text: string, def: AgentDefinition): Finding[] {
463 if (/NO_FINDINGS/i.test(text)) return [];
464
465 const blocks = text.split(/FINDING:/gi).slice(1, 4); // hard cap: 3 findings per agent
466 const findings: Finding[] = [];
467
468 for (const block of blocks) {
469 const end = block.indexOf("END_FINDING");
470 const body = end >= 0 ? block.slice(0, end) : block;
471
472 const contentMatch = body.match(/Content:\s*([\s\S]+?)(?=Citation:|Confidence:|END_FINDING|$)/i);
473 const citationMatches = [
474 ...body.matchAll(
475 /Citation:\s*SOURCE=(.+?)\s*\|\s*QUOTE=(.+?)(?:\s*\|\s*PAGE=(.+?))?(?=\nCitation:|\nConfidence:|END_FINDING|$)/gis,
476 ),
477 ];
478 const confidenceMatch = body.match(/Confidence:\s*([\d.]+)/i);
479
480 const content = contentMatch?.[1]?.trim();
481 if (!content) continue;
482
483 const citations: Citation[] = citationMatches.slice(0, 50).map((m) => ({
484 source: m[1].trim().slice(0, 200),
485 quote: m[2].trim().slice(0, 500),
486 page: m[3]?.trim() ? parseInt(m[3].trim(), 10) : undefined,
487 mechanicallyVerified: false,
488 }));
489
490 findings.push({
491 id: uuidv4(),
492 agentId: def.id,
493 agentName: def.name,
494 content,
495 citations,
496 confidence: (() => { const rawConf = parseFloat(confidenceMatch?.[1] ?? "0.7"); return Number.isFinite(rawConf) ? Math.min(1, Math.max(0, rawConf)) : 0.7; })(),
497 challenged: false,
498 resolved: false,
499 round: 0, // caller sets this
500 timestamp: new Date(),
501 });
502 }
503
504 return findings;
505}