/** * The main heartbeat execution. * * Implements the tool-calling loop: * * for turn in 1..maxTurns: * resp = minimax.chat(messages + tools) * if no tool calls → final answer * for each tool call: * if same call seen 3 times → break the loop * result = callTool(...) * append tool result to messages * * Streams events to the transcript for the live Working panel, tracks * cumulative cost, and returns a final AdapterResult. */ import { randomUUID } from "node:crypto"; import type { AdapterConfig, AdapterResult, AgentContext, ChatMessage, CostSummary, IssueRef, RunContext, ToolCall, TranscriptEvent, Usage, } from "./types.js"; import { MiniMaxClient, type ChatResponse } from "./client.js"; import { listTools, callTool, type ToolContext } from "./tools.js"; import { buildSystemPrompt } from "./prompt.js"; import { loadSkills } from "./skills.js"; import { sessionCodec, newSession, appendTranscript, type SessionState } from "./session.js"; import { aggregateUsage, aggregateCost, computeCost } from "./cost.js"; import type { JsonObject } from "./types.js"; const isoNow = () => new Date().toISOString(); export interface ExecuteArgs { config: AdapterConfig; agent: AgentContext; run: RunContext; issue: IssueRef; parentChain?: IssueRef[]; /** When provided, resume an existing session instead of starting fresh. */ resumeSessionState?: SessionState; /** Custom fetch for tool HTTP calls (tests inject this). */ toolFetch?: typeof fetch; /** Custom MiniMax client (tests inject this). */ client?: MiniMaxClient; } export async function execute(args: ExecuteArgs): Promise { const { config, agent, run, issue, parentChain = [] } = args; const client = args.client ?? new MiniMaxClient(config); // Resume or start a new session. const session: SessionState = args.resumeSessionState ? { ...args.resumeSessionState, header: { ...args.resumeSessionState.header, lastUsedAt: isoNow() } } : newSession(config.model); // Initial system message + user task. const skills = loadSkills(agent.workspacePath, config.skillsDir); const systemMessage: ChatMessage = { role: "system", content: buildSystemPrompt({ agent, issue, parentChain, skills, model: config.model }), }; const userMessage: ChatMessage = { role: "user", content: buildTaskPrompt(issue, parentChain), }; session.messages.push(systemMessage, userMessage); session.header.messageCount = session.messages.length; const transcript: TranscriptEvent[] = [ { kind: "init", ts: isoNow(), model: config.model, sessionId: session.header.sessionId }, { kind: "user", ts: isoNow(), content: userMessage.content ?? "" }, ]; for (const t of transcript) appendTranscript(session, t as any); const toolCtx: ToolContext = { config, companyId: run.companyId, issueId: run.issueId, fetchImpl: args.toolFetch, approvalGated: config.approvalGated, }; const allUsages: Usage[] = []; const allCosts: CostSummary[] = []; const allToolCalls: ToolCall[] = []; let finalContent = ""; let lastReasoning: string | null = null; let finalStatus: "succeeded" | "failed" = "succeeded"; let errorMessage: string | undefined; // Repeat-call loop break: detect 3+ identical consecutive tool calls. const recentCallSignatures: string[] = []; outer: for (let turn = 1; turn <= config.maxTurns; turn++) { let response: ChatResponse; try { response = await client.chat({ model: config.model, messages: session.messages, tools: listTools(), tool_choice: "auto", temperature: 0.7, max_tokens: 4096, }); } catch (err) { finalStatus = "failed"; errorMessage = `minimax_chat_failed: ${err instanceof Error ? err.message : String(err)}`; transcript.push({ kind: "result", ts: isoNow(), subtype: "error", isError: true, costUsd: 0, inputTokens: 0, outputTokens: 0, reason: errorMessage }); break; } allUsages.push(response.usage); allCosts.push(computeCost(response.usage, config.priceInputPer1M, config.priceOutputPer1M)); if (response.content) finalContent = response.content; if (response.reasoning) lastReasoning = response.reasoning; transcript.push({ kind: "assistant", ts: isoNow(), content: response.content ?? undefined, reasoning: response.reasoning ?? undefined, }); appendTranscript(session, transcript[transcript.length - 1] as any); // No tool calls → final answer. if (response.toolCalls.length === 0) { break; } // Append the assistant turn with its tool calls so the next chat // round carries the function-call history. session.messages.push({ role: "assistant", content: response.content ?? null, tool_calls: response.toolCalls, reasoning: response.reasoning ?? undefined, }); session.header.messageCount = session.messages.length; // Loop-break detection. for (const tc of response.toolCalls) { allToolCalls.push(tc); const sig = `${tc.function.name}:${tc.function.arguments}`; recentCallSignatures.push(sig); if (recentCallSignatures.length > 6) recentCallSignatures.shift(); // Only check for the loop when we have at least 3 entries, // and the last 3 are all the same call. const sameInWindow = recentCallSignatures.length >= 3 && recentCallSignatures.slice(-3).every((s) => s === sig); if (sameInWindow) { const warn: TranscriptEvent = { kind: "warning", ts: isoNow(), message: `Tool ${tc.function.name} called 3x in a row with same args — breaking loop.`, }; transcript.push(warn); finalStatus = "failed"; errorMessage = `repeat_call_loop_break: ${tc.function.name}`; break outer; } } // Enforce the per-turn cap. if (response.toolCalls.length > config.maxToolCallsPerTurn) { const warn: TranscriptEvent = { kind: "warning", ts: isoNow(), message: `Model attempted ${response.toolCalls.length} tool calls; truncating to ${config.maxToolCallsPerTurn}.`, }; transcript.push(warn); response.toolCalls = response.toolCalls.slice(0, config.maxToolCallsPerTurn); } // Execute each tool call and append results. for (const tc of response.toolCalls) { const callId = tc.id || `call_${randomUUID().slice(0, 8)}`; let parsedArgs: unknown = {}; try { parsedArgs = JSON.parse(tc.function.arguments || "{}"); } catch { parsedArgs = {}; } transcript.push({ kind: "tool_call", ts: isoNow(), name: tc.function.name, input: (parsedArgs as JsonObject) ?? {}, callId, }); const { output, isError } = await callTool(tc.function.name, tc.function.arguments, toolCtx); const toolMsg: ChatMessage = { role: "tool", tool_call_id: callId, content: JSON.stringify(output), name: tc.function.name, }; session.messages.push(toolMsg); transcript.push({ kind: "tool_result", ts: isoNow(), callId, output: output as unknown as JsonObject, isError, }); appendTranscript(session, transcript[transcript.length - 1] as any); } session.header.messageCount = session.messages.length; session.header.lastUsedAt = isoNow(); } // Aggregate cost & usage. const totalUsage = aggregateUsage(allUsages); const totalCost = aggregateCost(allCosts); const costUsd = totalCost.totalUsd; // Final transcript event. transcript.push({ kind: "result", ts: isoNow(), subtype: finalStatus === "succeeded" ? "completed" : "error", isError: finalStatus !== "succeeded", costUsd, inputTokens: totalUsage.inputTokens, outputTokens: totalUsage.outputTokens, reason: errorMessage, }); return { status: finalStatus, result: finalContent || lastReasoning || undefined, errorMessage, usage: totalUsage, costUsd, model: config.model, provider: "minimax", transcript, sessionState: sessionCodec.serialize(session as unknown as Record) as unknown as Record, }; } function buildTaskPrompt(issue: IssueRef, parentChain: IssueRef[]): string { const lines: string[] = []; lines.push("Begin work on this task now."); lines.push(""); lines.push(`Issue: ${issue.identifier ?? issue.id}`); if (issue.title) lines.push(`Title: ${issue.title}`); if (issue.status) lines.push(`Status: ${issue.status}`); if (parentChain.length) { lines.push(""); lines.push("This task is in service of (read top-to-bottom for the 'why'):"); for (const p of parentChain) lines.push(` → ${p.identifier ?? p.id} — ${p.title ?? ""}`); } lines.push(""); lines.push("Use the tools available to you. When you've finished, post a summary comment and set the issue to `done`."); return lines.join("\n"); }