- Add model-tags parser for @Tag syntax in chat messages - Support Anthropic models (Sonnet, Haiku, Opus) via @tag - Remove Qdrant vector database from infrastructure and configs - Simplify license model config to use null fallbacks - Add greeting stream after model switch via @tag - Fix protobuf field names to camelCase for v7 compatibility - Add 429 rate limit retry logic with exponential backoff - Remove RAG references from agent harness documentation
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
|
import type { HarnessEvent, SubagentChunkEvent, SubagentThinkingEvent } from '../harness-events.js';
|
|
import { getToolLabel } from '../tool-labels.js';
|
|
import type { MCPClientConnector } from '../mcp-client.js';
|
|
import type { WorkspaceManager } from '../../workspace/workspace-manager.js';
|
|
import type { ToolRegistry } from '../../tools/tool-registry.js';
|
|
import type { MCPToolInfo } from '../../tools/mcp/mcp-tool-wrapper.js';
|
|
import { WikiLoader, type SpawnContext } from './wiki-loader.js';
|
|
|
|
/** All platform tool names available to every subagent. */
|
|
const ALL_PLATFORM_TOOLS = ['SymbolLookup', 'GetChartData', 'GetTicker24h', 'WebSearch', 'FetchPage', 'ArxivSearch'];
|
|
|
|
/**
|
|
* Streaming filter that strips triple-backtick fenced code blocks from text as it
|
|
* arrives in chunks. Holds back at most 2 characters of look-ahead so normal text
|
|
* streams through with no perceptible delay.
|
|
*/
|
|
class FenceFilter {
|
|
private buf = '';
|
|
private inFence = false;
|
|
|
|
write(chunk: string): string {
|
|
this.buf += chunk;
|
|
return this.drain(false);
|
|
}
|
|
|
|
end(): string {
|
|
return this.drain(true);
|
|
}
|
|
|
|
private drain(final: boolean): string {
|
|
let out = '';
|
|
while (true) {
|
|
if (!this.inFence) {
|
|
const start = this.buf.indexOf('```');
|
|
if (start === -1) {
|
|
const keep = final ? this.buf.length : Math.max(0, this.buf.length - 2);
|
|
out += this.buf.slice(0, keep);
|
|
this.buf = this.buf.slice(keep);
|
|
break;
|
|
}
|
|
out += this.buf.slice(0, start);
|
|
const headerEnd = this.buf.indexOf('\n', start + 3);
|
|
if (headerEnd === -1 && !final) {
|
|
this.buf = this.buf.slice(start);
|
|
break;
|
|
}
|
|
this.inFence = true;
|
|
this.buf = headerEnd !== -1 ? this.buf.slice(headerEnd + 1) : '';
|
|
} else {
|
|
const end = this.buf.indexOf('```');
|
|
if (end === -1) {
|
|
this.buf = final ? '' : this.buf.slice(Math.max(0, this.buf.length - 2));
|
|
break;
|
|
}
|
|
this.inFence = false;
|
|
const closingEnd = this.buf.indexOf('\n', end + 3);
|
|
this.buf = closingEnd !== -1 ? this.buf.slice(closingEnd + 1) : this.buf.slice(end + 3);
|
|
}
|
|
}
|
|
// Collapse blank lines left where code blocks were removed
|
|
return out.replace(/\n{3,}/g, '\n\n');
|
|
}
|
|
}
|
|
|
|
export interface SpawnInput {
|
|
agentName: string;
|
|
instruction: string;
|
|
mcpClient?: MCPClientConnector;
|
|
availableMCPTools?: MCPToolInfo[];
|
|
workspaceManager?: WorkspaceManager;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
/**
|
|
* SpawnService creates isolated subagent invocations on demand.
|
|
*
|
|
* Each call to streamSpawn():
|
|
* 1. Loads the agent's wiki page (frontmatter + body)
|
|
* 2. Builds a SystemMessage from base prompt + agent body + static imports
|
|
* 3. Loads dynamic imports as a HumanMessage prefix (never cached)
|
|
* 4. Resolves tools from the frontmatter tool lists
|
|
* 5. Creates a fresh createReactAgent and streams events
|
|
*
|
|
* This replaces the old per-subagent BaseSubagent pattern with a stateless
|
|
* factory that reads configuration from markdown frontmatter.
|
|
*/
|
|
export class SpawnService {
|
|
constructor(
|
|
private readonly wikiLoader: WikiLoader,
|
|
private readonly toolRegistry: ToolRegistry,
|
|
private readonly modelFn: (maxTokens?: number) => Promise<BaseChatModel>,
|
|
private readonly logger: FastifyBaseLogger,
|
|
) {}
|
|
|
|
/**
|
|
* Stream events from a subagent invocation.
|
|
* Yields HarnessEvents (subagent_chunk, subagent_thinking, subagent_tool_call).
|
|
* Returns the final text result (or JSON with images when spawnsImages is set).
|
|
*/
|
|
async *streamSpawn(input: SpawnInput): AsyncGenerator<HarnessEvent, string> {
|
|
const { agentName, instruction, mcpClient, availableMCPTools, workspaceManager, signal } = input;
|
|
|
|
this.logger.info({ agentName, instruction: instruction.substring(0, 100) }, 'SpawnService: starting');
|
|
|
|
// Load agent wiki page
|
|
const agentPage = await this.wikiLoader.loadAgentPage(agentName);
|
|
const fm = agentPage.frontmatter;
|
|
|
|
// Build SpawnContext for virtual pages
|
|
const ctx: SpawnContext = { mcpClient, workspaceManager };
|
|
|
|
// Load base prompt (index.md + tools.md) — stable, tier-1 cacheable
|
|
const basePrompt = await this.wikiLoader.getBasePrompt();
|
|
|
|
// Load static imports (appended to agent body, tier-2 cacheable together)
|
|
const staticImports = fm.static_imports?.length
|
|
? await this.wikiLoader.loadStaticImports(fm.static_imports)
|
|
: '';
|
|
|
|
// Build the static SystemMessage (base + agent body + static imports)
|
|
const staticContent = [basePrompt, agentPage.body, staticImports]
|
|
.filter(Boolean)
|
|
.join('\n\n---\n\n');
|
|
const systemMessage = new SystemMessage(staticContent);
|
|
|
|
// Load dynamic imports (never cached, injected as a HumanMessage prefix)
|
|
const dynamicContent = fm.dynamic_imports?.length
|
|
? await this.wikiLoader.loadDynamicImports(fm.dynamic_imports, ctx)
|
|
: '';
|
|
|
|
// Build HumanMessage: dynamic context (if any) + instruction
|
|
const humanContent = dynamicContent
|
|
? `${dynamicContent}\n\n---\n\n${instruction}`
|
|
: instruction;
|
|
const humanMessage = new HumanMessage(humanContent);
|
|
|
|
// Set up image capture array (per-call, not shared mutable state)
|
|
const imageCapture: Array<{ data: string; mimeType: string }> = [];
|
|
|
|
const onImage = fm.spawnsImages
|
|
? (img: { data: string; mimeType: string }) => imageCapture.push(img)
|
|
: undefined;
|
|
|
|
const onWorkspaceMutation = workspaceManager
|
|
? (storeName: string, newState: unknown) => {
|
|
workspaceManager.setState(storeName, newState).catch((err: Error) => {
|
|
this.logger.error({ err, storeName }, 'Failed to sync workspace after spawn mutation');
|
|
});
|
|
}
|
|
: undefined;
|
|
|
|
// All subagents get all platform tools and all MCP tools.
|
|
// Per-agent tool restrictions via frontmatter are no longer used.
|
|
const tools = await this.toolRegistry.resolveTools(
|
|
ALL_PLATFORM_TOOLS,
|
|
['*'],
|
|
mcpClient,
|
|
availableMCPTools,
|
|
workspaceManager,
|
|
onImage,
|
|
onWorkspaceMutation,
|
|
);
|
|
|
|
this.logger.info(
|
|
{ agentName, toolCount: tools.length, toolNames: tools.map(t => t.name) },
|
|
'SpawnService: tools resolved'
|
|
);
|
|
|
|
// Create model (respecting per-agent maxTokens)
|
|
const model = await this.modelFn(fm.maxTokens);
|
|
|
|
// Create a fresh ReactAgent for this invocation
|
|
const agent = createReactAgent({
|
|
llm: model,
|
|
tools,
|
|
prompt: systemMessage,
|
|
});
|
|
|
|
const recursionLimit = fm.recursionLimit ?? 30;
|
|
|
|
// Emit an initial indicator so the UI shows the subagent has started
|
|
yield { type: 'subagent_tool_call', agentName, toolName: 'Thinking...', label: 'Thinking...' };
|
|
|
|
const stream = agent.stream(
|
|
{ messages: [humanMessage] },
|
|
{ streamMode: ['messages', 'updates'], recursionLimit, signal }
|
|
);
|
|
|
|
let finalText = '';
|
|
const fenceFilter = new FenceFilter();
|
|
|
|
for await (const [mode, data] of await stream) {
|
|
if (signal?.aborted) break;
|
|
|
|
if (mode === 'messages') {
|
|
for (const chunk of SpawnService.extractStreamChunks(data, agentName)) {
|
|
const filtered = fenceFilter.write(chunk.content);
|
|
if (filtered) yield { ...chunk, content: filtered };
|
|
}
|
|
} else if (mode === 'updates') {
|
|
if ((data as any).agent?.messages) {
|
|
for (const msg of (data as any).agent.messages as any[]) {
|
|
if (msg.tool_calls?.length) {
|
|
for (const tc of msg.tool_calls) {
|
|
yield {
|
|
type: 'subagent_tool_call',
|
|
agentName,
|
|
toolName: tc.name,
|
|
label: getToolLabel(tc.name),
|
|
};
|
|
}
|
|
} else {
|
|
const content = SpawnService.extractFinalText(msg);
|
|
if (content) finalText = content;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const tail = fenceFilter.end();
|
|
if (tail) yield { type: 'subagent_chunk', agentName, content: tail };
|
|
|
|
this.logger.info(
|
|
{ agentName, textLength: finalText.length, imageCount: imageCapture.length },
|
|
'SpawnService: finished'
|
|
);
|
|
|
|
// If this agent captures images, return JSON with text + images
|
|
if (fm.spawnsImages && imageCapture.length > 0) {
|
|
return JSON.stringify({ text: finalText, images: imageCapture });
|
|
}
|
|
|
|
return finalText;
|
|
}
|
|
|
|
/**
|
|
* Extract subagent_chunk / subagent_thinking events from a LangGraph `messages` stream datum.
|
|
* Only processes AIMessageChunks — ToolMessages (identified by tool_call_id) are skipped
|
|
* because their content is raw tool result data, not agent narrative text.
|
|
*/
|
|
static extractStreamChunks(
|
|
data: unknown,
|
|
agentName: string,
|
|
): Array<SubagentChunkEvent | SubagentThinkingEvent> {
|
|
const msg = Array.isArray(data) ? (data as unknown[])[0] : data;
|
|
// ToolMessages have tool_call_id; AIMessageChunks don't — skip tool results
|
|
if ((msg as any)?.tool_call_id != null) return [];
|
|
const content = (msg as any)?.content;
|
|
if (typeof content === 'string') {
|
|
return content ? [{ type: 'subagent_chunk', agentName, content }] : [];
|
|
}
|
|
if (Array.isArray(content)) {
|
|
const chunks: Array<SubagentChunkEvent | SubagentThinkingEvent> = [];
|
|
for (const block of content as any[]) {
|
|
if (block?.type === 'thinking' && typeof block.thinking === 'string' && block.thinking) {
|
|
chunks.push({ type: 'subagent_thinking', agentName, content: block.thinking });
|
|
} else if (block?.type === 'text' && typeof block.text === 'string' && block.text) {
|
|
chunks.push({ type: 'subagent_chunk', agentName, content: block.text });
|
|
}
|
|
}
|
|
return chunks;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Extract the final text from an `updates`-mode agent message.
|
|
*/
|
|
static extractFinalText(msg: any): string {
|
|
if (typeof msg?.content === 'string') return msg.content;
|
|
if (Array.isArray(msg?.content)) {
|
|
return (msg.content as any[])
|
|
.filter((b: any) => b?.type === 'text' && typeof b.text === 'string')
|
|
.map((b: any) => b.text as string)
|
|
.join('');
|
|
}
|
|
return '';
|
|
}
|
|
}
|