Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf0a8b2743 | |||
| fb32b97857 | |||
| a9bcdd77dd | |||
| 3501548543 | |||
| 1256dd8025 | |||
| b36abf39d4 |
+340
-281
@@ -9,64 +9,64 @@ const DEFAULT_MODEL = "qwen3.6:35b";
|
||||
const MAX_ITERATIONS = 100;
|
||||
|
||||
interface OllamaTool {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
async function buildMcpClient(mcpServerUrl: string): Promise<Client> {
|
||||
const client = new Client(
|
||||
{ name: "shockbot-agent", version: "0.1.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl));
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
const client = new Client(
|
||||
{ name: "shockbot-agent", version: "0.1.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl));
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
}
|
||||
|
||||
async function getOllamaTools(mcpClient: Client): Promise<OllamaTool[]> {
|
||||
const { tools } = await mcpClient.listTools();
|
||||
return tools.map((t) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description ?? "",
|
||||
parameters: (t.inputSchema as Record<string, unknown>) ?? {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
}));
|
||||
const { tools } = await mcpClient.listTools();
|
||||
return tools.map((t) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description ?? "",
|
||||
parameters: (t.inputSchema as Record<string, unknown>) ?? {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function callMcpTool(
|
||||
mcpClient: Client,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
mcpClient: Client,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const result = await mcpClient.callTool({
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
});
|
||||
const content = result.content as
|
||||
| Array<{ type: string; text?: string }>
|
||||
| undefined;
|
||||
if (!content || content.length === 0)
|
||||
return JSON.stringify({ success: true });
|
||||
const text = content
|
||||
.map((c) => (c.type === "text" ? (c.text ?? "") : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return text || JSON.stringify(result);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.debug(`Tool ${toolName} error: ${msg}`);
|
||||
return JSON.stringify({ error: msg });
|
||||
}
|
||||
try {
|
||||
const result = await mcpClient.callTool({
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
});
|
||||
const content = result.content as
|
||||
| Array<{ type: string; text?: string }>
|
||||
| undefined;
|
||||
if (!content || content.length === 0)
|
||||
return JSON.stringify({ success: true });
|
||||
const text = content
|
||||
.map((c) => (c.type === "text" ? (c.text ?? "") : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return text || JSON.stringify(result);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.debug(`Tool ${toolName} error: ${msg}`);
|
||||
return JSON.stringify({ error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,260 +76,319 @@ async function callMcpTool(
|
||||
* Never touches system/user/assistant messages — only tool messages.
|
||||
*/
|
||||
function pruneToolMessages(messages: Message[], keepRecent = 6): Message[] {
|
||||
const toolIndices: number[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].role === "tool") toolIndices.push(i);
|
||||
}
|
||||
const pruneCount = Math.max(0, toolIndices.length - keepRecent);
|
||||
if (pruneCount === 0) return messages;
|
||||
const toolIndices: number[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].role === "tool") toolIndices.push(i);
|
||||
}
|
||||
const pruneCount = Math.max(0, toolIndices.length - keepRecent);
|
||||
if (pruneCount === 0) return messages;
|
||||
|
||||
const toPrune = new Set(toolIndices.slice(0, pruneCount));
|
||||
let pruned = 0;
|
||||
const result = messages.map((msg, i) => {
|
||||
if (!toPrune.has(i)) return msg;
|
||||
const originalLen =
|
||||
typeof msg.content === "string" ? msg.content.length : 0;
|
||||
pruned++;
|
||||
return {
|
||||
...msg,
|
||||
content: `[pruned: was ${originalLen} chars — context limit approached]`,
|
||||
};
|
||||
});
|
||||
log.info(`» pruned ${pruned} old tool message(s) to reduce context`);
|
||||
return result;
|
||||
const toPrune = new Set(toolIndices.slice(0, pruneCount));
|
||||
let pruned = 0;
|
||||
const result = messages.map((msg, i) => {
|
||||
if (!toPrune.has(i)) return msg;
|
||||
const originalLen =
|
||||
typeof msg.content === "string" ? msg.content.length : 0;
|
||||
pruned++;
|
||||
return {
|
||||
...msg,
|
||||
content: `[pruned: was ${originalLen} chars — context limit approached]`,
|
||||
};
|
||||
});
|
||||
log.info(`» pruned ${pruned} old tool message(s) to reduce context`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function unloadModel(ollama: Ollama, model: string): Promise<void> {
|
||||
try {
|
||||
await ollama.generate({ model, keep_alive: 0, prompt: "" });
|
||||
log.info(`» unloaded model ${model} from Ollama`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.warning(`» failed to unload model ${model}: ${msg}`);
|
||||
}
|
||||
try {
|
||||
await ollama.generate({ model, keep_alive: 0, prompt: "" });
|
||||
log.info(`» unloaded model ${model} from Ollama`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.warning(`» failed to unload model ${model}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runOllamaLoop(ctx: AgentRunContext): Promise<AgentResult> {
|
||||
const ollamaHost = process.env.OLLAMA_HOST ?? "";
|
||||
if (!ollamaHost) {
|
||||
const errorMsg =
|
||||
"OLLAMA_HOST environment variable is not set. Please set it to the URL of your Ollama instance.";
|
||||
log.error(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
const ollamaHost = process.env.OLLAMA_HOST ?? "";
|
||||
if (!ollamaHost) {
|
||||
const errorMsg =
|
||||
"OLLAMA_HOST environment variable is not set. Please set it to the URL of your Ollama instance.";
|
||||
log.error(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
const model = ctx.model ?? process.env.OLLAMA_MODEL ?? DEFAULT_MODEL;
|
||||
const numCtx = ctx.payload.contextWindow ?? 262144;
|
||||
const model = ctx.model ?? process.env.OLLAMA_MODEL ?? DEFAULT_MODEL;
|
||||
const numCtx = ctx.payload.contextWindow ?? 262144;
|
||||
|
||||
log.info(`» connecting to Ollama at ${ollamaHost}, model ${model}`);
|
||||
log.info(`» connecting to Ollama at ${ollamaHost}, model ${model}`);
|
||||
|
||||
const ollama = new Ollama({ host: ollamaHost });
|
||||
const mcpClient = await buildMcpClient(ctx.mcpServerUrl);
|
||||
const ollama = new Ollama({ host: ollamaHost });
|
||||
const mcpClient = await buildMcpClient(ctx.mcpServerUrl);
|
||||
|
||||
log.info("» fetching MCP tool list...");
|
||||
const tools = await getOllamaTools(mcpClient);
|
||||
log.info(`» ${tools.length} tools available`);
|
||||
log.info("» fetching MCP tool list...");
|
||||
const tools = await getOllamaTools(mcpClient);
|
||||
log.info(`» ${tools.length} tools available`);
|
||||
|
||||
let messages: Message[] = [
|
||||
let messages: Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: ctx.instructions.full,
|
||||
},
|
||||
];
|
||||
|
||||
// Tools that signal the agent has produced its final output.
|
||||
const OUTPUT_TOOLS = new Set([
|
||||
"create_pull_request_review",
|
||||
"report_progress",
|
||||
"set_output",
|
||||
]);
|
||||
|
||||
let iterations = 0;
|
||||
let pendingModeNudge = false;
|
||||
let calledOutputTool = false;
|
||||
let addedContinueNudge = false;
|
||||
let reportProgressCount = 0;
|
||||
let editCommentCount = 0;
|
||||
let selectedModeName = "";
|
||||
|
||||
while (iterations < MAX_ITERATIONS) {
|
||||
iterations++;
|
||||
log.info(`» Ollama turn ${iterations}/${MAX_ITERATIONS}...`);
|
||||
|
||||
// Non-streaming with a heartbeat timer so the activity monitor stays alive
|
||||
// during long prefill. Streaming was tried but Ollama only emits one tool
|
||||
// call per chunk — batched tool calls collapse to one-per-turn, turning a
|
||||
// 7-turn run into 26 turns. The heartbeat fires every 60s to prevent the
|
||||
// 300s activity timeout from triggering during large-context prefill.
|
||||
let response: Awaited<ReturnType<typeof ollama.chat>>;
|
||||
const turnStart = Date.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
log.info(`» still waiting for model... (${Math.round((Date.now() - turnStart) / 1000)}s)`);
|
||||
}, 60_000);
|
||||
try {
|
||||
response = await retry(
|
||||
() => ollama.chat({
|
||||
model,
|
||||
messages,
|
||||
tools,
|
||||
keep_alive: -1,
|
||||
think: false,
|
||||
options: { num_ctx: numCtx, temperature: 0.1 },
|
||||
}),
|
||||
{
|
||||
role: "user",
|
||||
content: ctx.instructions.full,
|
||||
delaysMs: [3_000, 8_000],
|
||||
shouldRetry: (err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /unexpected EOF|XML syntax error|ECONNRESET|ETIMEDOUT|fetch failed/i.test(msg);
|
||||
},
|
||||
label: `Ollama turn ${iterations}`,
|
||||
},
|
||||
];
|
||||
);
|
||||
} catch (err) {
|
||||
clearInterval(heartbeat);
|
||||
await unloadModel(ollama, model);
|
||||
const lastError = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Ollama error: ${lastError}`);
|
||||
return { success: false, error: `Ollama request failed: ${lastError}` };
|
||||
}
|
||||
clearInterval(heartbeat);
|
||||
|
||||
// Tools that signal the agent has produced its final output.
|
||||
const OUTPUT_TOOLS = new Set([
|
||||
"create_pull_request_review",
|
||||
"report_progress",
|
||||
"set_output",
|
||||
]);
|
||||
const promptTokens = response.prompt_eval_count;
|
||||
const evalTokens = response.eval_count;
|
||||
const assistantMessage = response.message;
|
||||
|
||||
let iterations = 0;
|
||||
let pendingModeNudge = false;
|
||||
let calledOutputTool = false;
|
||||
let addedContinueNudge = false;
|
||||
|
||||
while (iterations < MAX_ITERATIONS) {
|
||||
iterations++;
|
||||
log.info(`» Ollama turn ${iterations}/${MAX_ITERATIONS}...`);
|
||||
|
||||
// Non-streaming with a heartbeat timer so the activity monitor stays alive
|
||||
// during long prefill. Streaming was tried but Ollama only emits one tool
|
||||
// call per chunk — batched tool calls collapse to one-per-turn, turning a
|
||||
// 7-turn run into 26 turns. The heartbeat fires every 60s to prevent the
|
||||
// 300s activity timeout from triggering during large-context prefill.
|
||||
let response: Awaited<ReturnType<typeof ollama.chat>>;
|
||||
const turnStart = Date.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
log.info(`» still waiting for model... (${Math.round((Date.now() - turnStart) / 1000)}s)`);
|
||||
}, 60_000);
|
||||
try {
|
||||
response = await retry(
|
||||
() => ollama.chat({
|
||||
model,
|
||||
messages,
|
||||
tools,
|
||||
keep_alive: -1,
|
||||
think: false,
|
||||
options: { num_ctx: numCtx, temperature: 0.1 },
|
||||
}),
|
||||
{
|
||||
delaysMs: [3_000, 8_000, 15_000, 30_000, 600_000], // up to 6 attempts over ~16 minutes
|
||||
shouldRetry: (err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /unexpected EOF|XML syntax error|ECONNRESET|ETIMEDOUT|fetch failed/i.test(msg);
|
||||
},
|
||||
label: `Ollama turn ${iterations}`,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
clearInterval(heartbeat);
|
||||
await unloadModel(ollama, model);
|
||||
const lastError = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Ollama error: ${lastError}`);
|
||||
return { success: false, error: `Ollama request failed: ${lastError}` };
|
||||
}
|
||||
clearInterval(heartbeat);
|
||||
|
||||
const promptTokens = response.prompt_eval_count;
|
||||
const evalTokens = response.eval_count;
|
||||
const assistantMessage = response.message;
|
||||
|
||||
if (promptTokens !== undefined) {
|
||||
const total = promptTokens + (evalTokens ?? 0);
|
||||
const pct = Math.round((total / numCtx) * 100);
|
||||
log.info(
|
||||
` context: ${promptTokens} prompt + ${evalTokens ?? 0} eval = ${total} tokens (${pct}% of ${numCtx} limit)`,
|
||||
);
|
||||
if (promptTokens > numCtx * 0.77) {
|
||||
messages = pruneToolMessages(messages);
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(assistantMessage);
|
||||
|
||||
const toolCalls: ToolCall[] | undefined = assistantMessage.tool_calls;
|
||||
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
log.debug(` model text: ${assistantMessage.content?.slice(0, 500)}`);
|
||||
|
||||
// If the model stopped before ever calling an output tool and we haven't
|
||||
// nudged yet, give it one more push to continue the workflow — regardless
|
||||
// of whether the mode nudge is still pending (the model may have stopped
|
||||
// right after select_mode before acting on the guidance).
|
||||
if (!calledOutputTool && !addedContinueNudge) {
|
||||
log.info(
|
||||
"» model stopped before completing task — nudging to continue",
|
||||
);
|
||||
addedContinueNudge = true;
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"Your task is not complete yet. Continue executing the workflow — " +
|
||||
"call the next required tool to finish. " +
|
||||
"Do not stop until you have submitted a review (create_pull_request_review) " +
|
||||
"or called report_progress with a final summary.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await unloadModel(ollama, model);
|
||||
|
||||
if (pendingModeNudge) {
|
||||
log.info("» agent finished after mode nudge (no tool calls)");
|
||||
} else {
|
||||
log.info("» agent finished (no tool calls)");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: assistantMessage.content || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
pendingModeNudge = false;
|
||||
|
||||
const calledSelectMode = toolCalls.some(
|
||||
(tc) => tc.function.name === "select_mode",
|
||||
);
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolName = toolCall.function.name;
|
||||
const toolArgs = toolCall.function.arguments;
|
||||
|
||||
log.info(`» calling tool: ${toolName}`);
|
||||
log.debug(` args: ${JSON.stringify(toolArgs)}`);
|
||||
|
||||
if (OUTPUT_TOOLS.has(toolName)) {
|
||||
calledOutputTool = true;
|
||||
}
|
||||
|
||||
if (ctx.onToolUse) {
|
||||
ctx.onToolUse({ toolName, input: toolArgs });
|
||||
}
|
||||
|
||||
const result = await callMcpTool(
|
||||
mcpClient,
|
||||
toolName,
|
||||
toolArgs as Record<string, unknown>,
|
||||
);
|
||||
log.debug(` result: ${result.slice(0, 200)}`);
|
||||
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: result,
|
||||
});
|
||||
}
|
||||
|
||||
// After the FIRST select_mode call, nudge the model to act on the guidance.
|
||||
// Only nudge once — repeated nudging causes a loop where the model keeps
|
||||
// re-calling select_mode instead of executing the workflow.
|
||||
if (calledSelectMode && !pendingModeNudge) {
|
||||
pendingModeNudge = true;
|
||||
|
||||
// Parse the selected mode name from the tool result so we can give a
|
||||
// more specific first-step instruction.
|
||||
let selectedMode = "";
|
||||
try {
|
||||
const lastToolMsg = messages[messages.length - 1];
|
||||
const parsed = JSON.parse(
|
||||
typeof lastToolMsg.content === "string" ? lastToolMsg.content : "",
|
||||
);
|
||||
if (typeof parsed?.modeName === "string")
|
||||
selectedMode = parsed.modeName;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const firstStep =
|
||||
selectedMode === "Review" || selectedMode === "IncrementalReview"
|
||||
? "Your first tool call must be checkout_pr with the PR number from the event context."
|
||||
: "Call the first tool required by the workflow now.";
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
`Good. You have selected ${selectedMode || "a"} mode and received the workflow. ` +
|
||||
"Do NOT call select_mode again. " +
|
||||
`${firstStep} ` +
|
||||
"Execute the complete workflow step by step until you call create_pull_request_review or report_progress.",
|
||||
});
|
||||
}
|
||||
if (promptTokens !== undefined) {
|
||||
const total = promptTokens + (evalTokens ?? 0);
|
||||
const pct = Math.round((total / numCtx) * 100);
|
||||
log.info(
|
||||
` context: ${promptTokens} prompt + ${evalTokens ?? 0} eval = ${total} tokens (${pct}% of ${numCtx} limit)`,
|
||||
);
|
||||
if (promptTokens > numCtx * 0.77) {
|
||||
messages = pruneToolMessages(messages);
|
||||
}
|
||||
}
|
||||
|
||||
await unloadModel(ollama, model);
|
||||
messages.push(assistantMessage);
|
||||
|
||||
log.warning(`» agent hit max iterations (${MAX_ITERATIONS})`);
|
||||
const toolCalls: ToolCall[] | undefined = assistantMessage.tool_calls;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Agent exceeded maximum iterations (${MAX_ITERATIONS})`,
|
||||
};
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
log.debug(` model text: ${assistantMessage.content?.slice(0, 500)}`);
|
||||
|
||||
// If the model stopped before ever calling an output tool and we haven't
|
||||
// nudged yet, give it one more push to continue the workflow — regardless
|
||||
// of whether the mode nudge is still pending (the model may have stopped
|
||||
// right after select_mode before acting on the guidance).
|
||||
if (!calledOutputTool && !addedContinueNudge) {
|
||||
log.info(
|
||||
"» model stopped before completing task — nudging to continue",
|
||||
);
|
||||
addedContinueNudge = true;
|
||||
const isReview =
|
||||
selectedModeName === "Review" || selectedModeName === "IncrementalReview";
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"Your task is not complete yet. Continue executing the workflow — " +
|
||||
"call the next required tool to finish. " +
|
||||
(isReview
|
||||
? "Do not stop until you have submitted a review via create_pull_request_review."
|
||||
: "Do not stop until you have submitted a review (create_pull_request_review) or called report_progress with a final summary."),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await unloadModel(ollama, model);
|
||||
|
||||
if (pendingModeNudge) {
|
||||
log.info("» agent finished after mode nudge (no tool calls)");
|
||||
} else {
|
||||
log.info("» agent finished (no tool calls)");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: assistantMessage.content || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
pendingModeNudge = false;
|
||||
|
||||
const calledSelectMode = toolCalls.some(
|
||||
(tc) => tc.function.name === "select_mode",
|
||||
);
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolName = toolCall.function.name;
|
||||
const toolArgs = toolCall.function.arguments;
|
||||
|
||||
log.info(`» calling tool: ${toolName}`);
|
||||
log.debug(` args: ${JSON.stringify(toolArgs)}`);
|
||||
|
||||
if (OUTPUT_TOOLS.has(toolName)) {
|
||||
calledOutputTool = true;
|
||||
}
|
||||
|
||||
if (ctx.onToolUse) {
|
||||
ctx.onToolUse({ toolName, input: toolArgs });
|
||||
}
|
||||
|
||||
const result = await callMcpTool(
|
||||
mcpClient,
|
||||
toolName,
|
||||
toolArgs as Record<string, unknown>,
|
||||
);
|
||||
log.debug(` result: ${result.slice(0, 200)}`);
|
||||
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: result,
|
||||
});
|
||||
|
||||
// After checkout_pr returns, extract and pin the diffPath as a user
|
||||
// message. Only tool messages are pruned — user messages survive context
|
||||
// pressure — so the model retains the exact diff file path and knows how
|
||||
// to call read_file with offset/limit to read diff ranges.
|
||||
if (toolName === "checkout_pr") {
|
||||
try {
|
||||
const parsed = JSON.parse(result) as Record<string, unknown>;
|
||||
if (typeof parsed?.diffPath === "string") {
|
||||
const dp = parsed.diffPath;
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
`The diff file is at: ${dp}\n\n` +
|
||||
`Read each file's diff by calling read_file on this path with start_line/end_line from the TOC.\n` +
|
||||
`Example — for "device.ts (163 lines, 1146-1308)": read_file(path="${dp}", start_line=1146, end_line=1308)\n` +
|
||||
`IMPORTANT: Do NOT call read_file on source files (apps/..., packages/...). ` +
|
||||
`Use read_file on the diff file path above with the TOC line ranges.`,
|
||||
});
|
||||
log.info(`» pinned diffPath to context: ${dp}`);
|
||||
}
|
||||
} catch {
|
||||
// best-effort: if checkout_pr result isn't JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
// report_progress called repeatedly means the model is stuck.
|
||||
if (toolName === "report_progress") {
|
||||
reportProgressCount++;
|
||||
if (reportProgressCount >= 2) {
|
||||
log.info("» report_progress loop — stopping");
|
||||
await unloadModel(ollama, model);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
// edit_issue_comment called repeatedly means the model is stuck.
|
||||
if (toolName === "edit_issue_comment") {
|
||||
editCommentCount++;
|
||||
if (editCommentCount >= 2) {
|
||||
log.info("» edit_issue_comment loop — stopping");
|
||||
await unloadModel(ollama, model);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After the FIRST select_mode call, nudge the model to act on the guidance.
|
||||
// Only nudge once — repeated nudging causes a loop where the model keeps
|
||||
// re-calling select_mode instead of executing the workflow.
|
||||
if (calledSelectMode && !pendingModeNudge) {
|
||||
pendingModeNudge = true;
|
||||
|
||||
// Parse the selected mode name from the tool result so we can give a
|
||||
// more specific first-step instruction.
|
||||
let selectedMode = "";
|
||||
try {
|
||||
const lastToolMsg = messages[messages.length - 1];
|
||||
const parsed = JSON.parse(
|
||||
typeof lastToolMsg.content === "string" ? lastToolMsg.content : "",
|
||||
);
|
||||
if (typeof parsed?.modeName === "string")
|
||||
selectedMode = parsed.modeName;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const isReviewMode =
|
||||
selectedMode === "Review" || selectedMode === "IncrementalReview";
|
||||
|
||||
selectedModeName = selectedMode;
|
||||
|
||||
const firstStep = isReviewMode
|
||||
? "Your first tool call must be checkout_pr. After that: (1) read diff ranges from the returned diffPath using the TOC line numbers — do NOT read full source files one-by-one, that will exhaust your context window; (2) every inline comment MUST identify a specific problem — never write praise or 'looks good' observations."
|
||||
: "Call the first tool required by the workflow now.";
|
||||
|
||||
const endCondition = isReviewMode
|
||||
? "Your ONLY valid final action is create_pull_request_review. Do NOT call report_progress — the mode workflow explicitly forbids it."
|
||||
: "Execute the complete workflow step by step until you call create_pull_request_review or report_progress.";
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
`Good. You have selected ${selectedMode || "a"} mode and received the workflow. ` +
|
||||
"Do NOT call select_mode again. " +
|
||||
`${firstStep} ` +
|
||||
endCondition,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await unloadModel(ollama, model);
|
||||
|
||||
log.warning(`» agent hit max iterations (${MAX_ITERATIONS})`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Agent exceeded maximum iterations (${MAX_ITERATIONS})`,
|
||||
};
|
||||
}
|
||||
|
||||
export const ollamaAgent = agent({
|
||||
name: "ollama",
|
||||
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||
return runOllamaLoop(ctx);
|
||||
},
|
||||
name: "ollama",
|
||||
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||
return runOllamaLoop(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user