This repository has been archived on 2026-03-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

527 lines
14 KiB
TypeScript

import { createOpenAI } from '@ai-sdk/openai';
import { streamText, tool, ModelMessage, jsonSchema } from 'ai';
import { t, toJSONSchema } from "structural";
import { Config, getModelFromConfig } from "../config.ts";
import * as toolMap from "../tools/tool-defs/index.ts";
import { HistoryItem, ToolCallRequestSchema, sequenceId, AssistantItem } from "../history.ts";
import { systemPrompt } from "../system-prompt.ts";
import { toLlmIR, LlmIR } from "../ir/llm-ir.ts";
import { fileTracker } from "../tools/file-tracker.ts";
import { autofixJson } from './autofix.ts';
import { tryexpr } from "../tryexpr.ts";
import { trackTokens } from "../token-tracker.ts";
import { applyContextWindow, messageHistoryTokens } from "../windowing.ts";
import * as logger from "../logger.ts";
async function toModelMessage(
messages: HistoryItem[],
appliedWindow: boolean,
config: Config,
): Promise<Array<ModelMessage>> {
const output: ModelMessage[] = [];
const irs = toLlmIR(messages);
irs.reverse();
const seenPaths = new Set<string>();
for(const ir of irs) {
if(ir.role === "file-tool-output") {
let seen = seenPaths.has(ir.path);
seenPaths.add(ir.path);
output.push(await modelMessageFromIr(ir, seen));
} else {
output.push(await modelMessageFromIr(ir, false));
}
}
output.reverse();
// Add system message
output.unshift({
role: "system",
content: await systemPrompt({
appliedWindow,
config,
exampleToolCall: JSON.stringify({
type: "function",
id: "SOME_STRING_ID",
function: {
name: "bash",
arguments: JSON.stringify({
cmd: "curl \"https://github.com/reissbaker/antipattern\"",
timeout: 10000,
} satisfies t.GetType<typeof toolMap.bash.ArgumentsSchema>),
},
}),
}),
});
return output;
}
async function modelMessageFromIr(ir: LlmIR, seenPath: boolean): Promise<ModelMessage> {
if(ir.role === "assistant") {
if(ir.reasoningContent || ir.openai) {
let openai = {};
if(ir.openai) {
openai = {
itemId: ir.openai.reasoningId || "",
reasoningEncryptedContent: ir.openai.encryptedReasoningContent,
};
}
const toolCalls = ir.toolCall ? [ ir.toolCall ] : [];
return {
role: "assistant",
content: [
{
type: "reasoning",
text: ir.reasoningContent || "",
providerOptions: {
openai: { ...openai },
},
},
{ type: "text", text: ir.content || " " },
...toolCalls.map(t => {
return {
type: "tool-call" as const,
toolCallId: t.toolCallId,
toolName: t.function.name,
input: t.function.arguments || {},
};
}),
],
providerOptions: {
openai: {
...openai,
},
}
};
}
const toolCalls = ir.toolCall ? [ ir.toolCall ] : [];
return {
role: "assistant",
content: [
{ type: "text", text: ir.content || " " },
...toolCalls.map(t => {
return {
type: "tool-call" as const,
toolCallId: t.toolCallId,
toolName: t.function.name,
input: t.function.arguments || {},
};
}),
],
providerOptions: {
openai: {
},
}
};
}
if(ir.role === "user") {
return {
role: "user",
content: ir.content,
};
}
if(ir.role === "tool-output" || ir.role === "file-tool-output") {
let content: string;
if(ir.role === "file-tool-output") {
if(seenPath) {
content = "Tool ran successfully.";
} else {
try {
content = await fileTracker.read(ir.path);
} catch {
content = "Tool ran successfully.";
}
}
} else {
content = ir.content;
}
return {
role: "tool",
content: [
{
type: "tool-result" as const,
toolName: ir.toolCall.function.name,
toolCallId: ir.toolCall.toolCallId,
output: {
type: "text" as const,
value: content,
},
}
],
};
}
if(ir.role === "tool-reject") {
return {
role: "tool",
content: [
{
type: "tool-result",
toolName: ir.toolCall.function.name,
toolCallId: ir.toolCall.toolCallId,
output: {
type: "text" as const,
value: "Tool call rejected by user. Your tool call did not run.",
},
}
],
};
}
if(ir.role === "tool-error") {
return {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: ir.toolCallId,
toolName: ir.toolName,
output: {
type: "text" as const,
value: `Error: ${ir.error}`,
},
}
],
};
}
if(ir.role === "file-outdated") {
return {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: ir.toolCall.toolCallId,
toolName: ir.toolCall.function.name,
output: {
type: "text",
value: "File could not be updated because it was modified after being last read. The latest version of the file has been automatically re-read and placed in your context space. Please try again.",
},
},
],
};
}
const _: "file-unreadable" = ir.role;
return {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: ir.toolCall.toolCallId,
toolName: ir.toolCall.function.name,
output: {
type: "text",
value: `File ${(ir as any).path} could not be read. Has it been deleted?`,
},
}
],
};
}
export async function runResponsesAgent({
config, modelOverride, history, onTokens, onAutofixJson, abortSignal
}: {
config: Config,
modelOverride: string | null,
history: HistoryItem[],
onTokens: (t: string, type: "reasoning" | "content") => any,
onAutofixJson: (done: Promise<void>) => any,
abortSignal: AbortSignal,
}) {
const modelConfig = getModelFromConfig(config, modelOverride);
const processedHistory = applyContextWindow(history, modelConfig.context);
const messages = await toModelMessage(
processedHistory.history,
processedHistory.appliedWindow,
config
);
// Convert tools to AI SDK format
const tools: Record<string, any> = {};
Object.entries(toolMap).forEach(([name, toolDef]) => {
const argJsonSchema = toJSONSchema("ignore", toolDef.ArgumentsSchema);
// Delete JSON schema fields unused by AI SDK
// @ts-ignore
delete argJsonSchema.$schema;
delete argJsonSchema.description;
// @ts-ignore
delete argJsonSchema.title;
tools[name] = tool({
description: `The ${name} tool`,
inputSchema: jsonSchema(argJsonSchema),
});
});
let reasoningConfig: {
reasoningEffort?: "low" | "medium" | "high",
reasoningSummary?: "auto",
} = {};
if(modelConfig.reasoning) {
reasoningConfig.reasoningEffort = modelConfig.reasoning;
reasoningConfig.reasoningSummary = "auto";
}
const openai = createOpenAI({
baseURL: modelConfig.baseUrl,
apiKey: process.env[modelConfig.apiEnvVar],
});
const result = streamText({
model: openai.responses(modelConfig.model),
messages,
tools,
abortSignal,
providerOptions: {
openai: {
...reasoningConfig,
store: false,
include: [ "reasoning.encrypted_content" ],
},
},
});
let content = "";
let reasoningId: string | undefined = undefined;
let reasoningContent: string | undefined = undefined;
let usage = {
input: 0,
output: 0,
reasoning: 0,
};
let encryptedReasoningContent: string | undefined = undefined;
// Handle streaming chunks
for await (const chunk of result.fullStream) {
if (abortSignal.aborted) break;
switch (chunk.type) {
case 'text-delta':
if (chunk.text) {
content += chunk.text;
onTokens(chunk.text, "content");
}
break;
case 'reasoning-start':
break;
case 'reasoning-delta':
if(chunk.text) {
if (reasoningContent == null) reasoningContent = "";
reasoningContent += chunk.text;
onTokens(chunk.text, "reasoning");
}
break;
case "reasoning-end":
const openai = chunk.providerMetadata ? chunk.providerMetadata["openai"] : {};
const encrypted = openai["reasoningEncryptedContent"];
if(encrypted && typeof encrypted === "string") {
encryptedReasoningContent = encrypted;
}
const itemId = openai["itemId"];
if(itemId && typeof itemId === "string") {
reasoningId = itemId;
}
break;
case 'tool-call':
// Tool call will be handled after streaming is complete
break;
case 'finish':
if (chunk.totalUsage) {
usage.input = chunk.totalUsage.inputTokens || 0;
usage.output = chunk.totalUsage.outputTokens || 0;
usage.reasoning = chunk.totalUsage.reasoningTokens || 0;
}
break;
}
}
// Track usage
if(usage.input !== 0 || usage.output !== 0) {
trackTokens(modelConfig.model, "input", usage.input);
trackTokens(modelConfig.model, "output", usage.output);
trackTokens(modelConfig.model, "output", usage.reasoning);
}
// Calculate token usage delta
let tokenDelta = 0;
if(usage.input !== 0 || usage.output !== 0) {
if(!abortSignal.aborted) {
const previousTokens = messageHistoryTokens(processedHistory.history);
tokenDelta = (usage.input + usage.output + usage.reasoning) - previousTokens;
}
}
let openaiSpecific = {};
if(reasoningId || encryptedReasoningContent) {
openaiSpecific = { openai: { reasoningId, encryptedReasoningContent } };
}
const assistantHistoryItem: AssistantItem = {
type: "assistant",
id: sequenceId(),
content, reasoningContent,
...openaiSpecific,
tokenUsage: tokenDelta,
};
// If aborted, don't try to parse tool calls
if(abortSignal.aborted) return history.concat([assistantHistoryItem]);
// Get tool calls
const toolCalls = await result.toolCalls;
if (toolCalls && toolCalls.length > 0) {
const firstToolCall = toolCalls[0];
const chatToolCall = {
toolCallId: firstToolCall.toolCallId,
toolName: firstToolCall.toolName,
args: firstToolCall.input,
}
const parseResult = await parseResponsesTool(
chatToolCall,
config,
onAutofixJson,
abortSignal,
);
if(parseResult.status === "error") {
return history.concat([
assistantHistoryItem,
{
type: "tool-malformed",
id: sequenceId(),
error: parseResult.message,
original: {
id: firstToolCall.toolCallId,
function: {
name: firstToolCall.toolName,
arguments: JSON.stringify(firstToolCall.input),
},
},
toolCallId: firstToolCall.toolCallId,
},
]);
}
return history.concat([
assistantHistoryItem,
{
type: "tool",
id: sequenceId(),
tool: parseResult.tool,
},
]);
}
return history.concat([assistantHistoryItem]);
}
type ParseToolResult = {
status: "success";
tool: t.GetType<typeof ToolCallRequestSchema>,
} | {
status: "error";
message: string
};
async function parseResponsesTool(
toolCall: { toolCallId: string; toolName: string; args: any },
config: Config,
onAutofixJson: (done: Promise<void>) => any,
abortSignal: AbortSignal,
): Promise<ParseToolResult> {
const name = toolCall.toolName;
if(!isValidToolName(name, config)) {
return {
status: "error",
message: `
Unknown tool ${name}. The only valid tool names are:
- ${validToolNames(config).join("\n- ")}
Please try calling a valid tool.
`.trim(),
};
}
const toolSchema = toolMap[name].Schema;
let args = toolCall.args;
// If args is a string, try to parse as JSON
if(typeof args === 'string') {
let [ err, parsedArgs ] = tryexpr(() => {
return JSON.parse(args);
});
if(err) {
const fixPromise = autofixJson(config, args, abortSignal);
onAutofixJson(fixPromise.then(() => {}));
const fixResponse = await fixPromise;
if(!fixResponse.success) {
return {
status: "error",
message: "Syntax error: invalid JSON in tool call arguments",
};
}
args = fixResponse.fixed;
} else {
args = parsedArgs;
}
}
try {
const parsed = toolSchema.slice({
name: toolCall.toolName,
arguments: args,
});
return {
status: "success",
tool: {
type: "function",
function: parsed,
toolCallId: toolCall.toolCallId,
},
};
} catch (e: unknown) {
logger.error("verbose", e);
logger.error("verbose", toolCall);
const error = e instanceof Error ? e.message : "Invalid arguments in tool call";
return {
status: "error",
message: `
Failed to parse tool call: ${error}. Make sure your arguments are valid and match the expected format.
`.trim(),
};
}
}
const TOOL_NAMES = new Set(Object.keys(toolMap));
function hasMcp(config: Config) {
if(config.mcpServers == null) return false;
if(Object.keys(config.mcpServers).length === 0) return false;
return true;
}
function isValidToolName(name: string, config: Config): name is ((keyof typeof toolMap) & string) {
if(!hasMcp(config) && name === "mcp") return false;
return TOOL_NAMES.has(name);
}
function validToolNames(config: Config) {
return Object.keys(toolMap).filter(t => {
if(hasMcp(config)) return true;
return t !== "mcp";
});
}