s-app-scoder-agent 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +368 -2
- package/dist/agent.js +478 -0
- package/dist/build.js +44 -0
- package/dist/config.js +93 -0
- package/dist/context.js +44 -0
- package/dist/diff.js +156 -0
- package/dist/index.js +285 -0
- package/dist/llm.js +266 -0
- package/dist/logger.js +78 -0
- package/dist/mcp.js +189 -0
- package/dist/permissions.js +277 -0
- package/dist/provider-anthropic.js +322 -0
- package/dist/retry.js +53 -0
- package/dist/session.js +143 -0
- package/dist/tools.js +356 -0
- package/dist/ui.js +96 -0
- package/dist/version.js +18 -0
- package/package.json +48 -3
package/dist/agent.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { loadPermissionLists } from "./config.js";
|
|
2
|
+
import { LlmClient, LlmError } from "./llm.js";
|
|
3
|
+
import { AnthropicClient } from "./provider-anthropic.js";
|
|
4
|
+
import { AGENT_TOOLS, getTool } from "./tools.js";
|
|
5
|
+
import { detectBuildCommand, runBuild } from "./build.js";
|
|
6
|
+
import { trimHistory, summariseOutput, claimsCompletion } from "./context.js";
|
|
7
|
+
import { PermissionChecker } from "./permissions.js";
|
|
8
|
+
import { SessionStore } from "./session.js";
|
|
9
|
+
import { JsonlLogger } from "./logger.js";
|
|
10
|
+
import { McpManager } from "./mcp.js";
|
|
11
|
+
import * as ui from "./ui.js";
|
|
12
|
+
const SYSTEM_PROMPT = `You are Scoder, an autonomous coding agent running in a terminal.
|
|
13
|
+
You complete coding tasks by using tools: reading/writing/editing files, listing
|
|
14
|
+
files, and running shell commands.
|
|
15
|
+
|
|
16
|
+
RULES:
|
|
17
|
+
1. Work autonomously. Do NOT ask the user questions mid-task; make reasonable
|
|
18
|
+
decisions and proceed.
|
|
19
|
+
2. When you believe the task is done, call run_command with the project's build
|
|
20
|
+
command (or the one the user specified). The loop ends only when the build
|
|
21
|
+
succeeds.
|
|
22
|
+
3. If the build fails, read the error output carefully, fix the code, and build
|
|
23
|
+
again. Iterate until it passes.
|
|
24
|
+
4. Prefer small, focused edits. Read a file before editing it.
|
|
25
|
+
5. If an external/environmental problem (missing dependency, wrong environment)
|
|
26
|
+
makes the task impossible, stop editing and report it clearly in your final
|
|
27
|
+
message.
|
|
28
|
+
6. Never invent requirements beyond the given task.
|
|
29
|
+
7. IMPORTANT: if a run_command is denied by permission policy, you will receive
|
|
30
|
+
an error message. Use this as a signal to find a safer alternative or to
|
|
31
|
+
communicate the limitation to the user.`;
|
|
32
|
+
export class Agent {
|
|
33
|
+
config;
|
|
34
|
+
client;
|
|
35
|
+
messages = [];
|
|
36
|
+
tools = AGENT_TOOLS.map((t) => t.spec);
|
|
37
|
+
ctx;
|
|
38
|
+
permissions;
|
|
39
|
+
session;
|
|
40
|
+
logger;
|
|
41
|
+
aborted = false;
|
|
42
|
+
streamFallbackWarned = false;
|
|
43
|
+
sessionCreatedAt;
|
|
44
|
+
currentTask = "";
|
|
45
|
+
lastBuildResult = null;
|
|
46
|
+
mcp = null;
|
|
47
|
+
/** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
|
|
48
|
+
runSignal = new AbortController();
|
|
49
|
+
constructor(config) {
|
|
50
|
+
this.config = config;
|
|
51
|
+
const commonOpts = {
|
|
52
|
+
baseUrl: config.baseUrl,
|
|
53
|
+
apiKey: config.apiKey,
|
|
54
|
+
model: config.model,
|
|
55
|
+
maxTokens: config.maxTokens,
|
|
56
|
+
temperature: config.temperature,
|
|
57
|
+
};
|
|
58
|
+
this.client =
|
|
59
|
+
config.provider === "anthropic"
|
|
60
|
+
? new AnthropicClient(commonOpts)
|
|
61
|
+
: new LlmClient({ ...commonOpts, stream: config.stream });
|
|
62
|
+
const permLists = loadPermissionLists(config.workdir);
|
|
63
|
+
this.permissions = new PermissionChecker({
|
|
64
|
+
mode: config.permissionMode,
|
|
65
|
+
allowCommands: permLists.allowCommands,
|
|
66
|
+
denyCommands: permLists.denyCommands,
|
|
67
|
+
nonInteractive: config.yes,
|
|
68
|
+
});
|
|
69
|
+
this.ctx = {
|
|
70
|
+
workdir: config.workdir,
|
|
71
|
+
permissions: this.permissions,
|
|
72
|
+
permissionMode: config.permissionMode,
|
|
73
|
+
};
|
|
74
|
+
this.session = new SessionStore(config.workdir, config.noSave !== true);
|
|
75
|
+
this.logger = JsonlLogger.forSession(config.workdir, this.session.id, config.logFile);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Handle SIGINT: abort gracefully after current tool finishes.
|
|
79
|
+
* Also kills any in-flight run_command child process via the context signal.
|
|
80
|
+
*/
|
|
81
|
+
abort() {
|
|
82
|
+
this.aborted = true;
|
|
83
|
+
this.runSignal.abort(new DOMException("interrupted by user", "AbortError"));
|
|
84
|
+
}
|
|
85
|
+
/** Reset the run-signal so a new run() can use a fresh controller. */
|
|
86
|
+
resetRunSignal() {
|
|
87
|
+
this.runSignal = new AbortController();
|
|
88
|
+
this.aborted = false;
|
|
89
|
+
// Keep the tool context in sync so run_command can observe SIGINT and
|
|
90
|
+
// terminate its child process immediately (long builds must be interruptible).
|
|
91
|
+
this.ctx.signal = this.runSignal.signal;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One LLM turn: streaming if enabled (renders deltas live), with automatic
|
|
95
|
+
* fallback to non-streaming when the provider/stream fails.
|
|
96
|
+
*/
|
|
97
|
+
async chatWithStream() {
|
|
98
|
+
const signal = this.runSignal.signal;
|
|
99
|
+
if (!this.config.stream) {
|
|
100
|
+
return this.client.chat(this.messages, this.tools, { signal });
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
let streamedText = "";
|
|
104
|
+
let final;
|
|
105
|
+
let started = false;
|
|
106
|
+
for await (const chunk of this.client.streamChat(this.messages, this.tools, { signal })) {
|
|
107
|
+
if (chunk.contentDelta) {
|
|
108
|
+
if (!started) {
|
|
109
|
+
ui.llmStreamStart("assistant");
|
|
110
|
+
started = true;
|
|
111
|
+
}
|
|
112
|
+
ui.llmStreamChunk(chunk.contentDelta);
|
|
113
|
+
streamedText += chunk.contentDelta;
|
|
114
|
+
}
|
|
115
|
+
if (chunk.final)
|
|
116
|
+
final = chunk.final;
|
|
117
|
+
}
|
|
118
|
+
if (started)
|
|
119
|
+
ui.llmStreamEnd();
|
|
120
|
+
if (final) {
|
|
121
|
+
if ((final.message.content == null || final.message.content === "") && streamedText) {
|
|
122
|
+
final.message.content = streamedText;
|
|
123
|
+
}
|
|
124
|
+
return final;
|
|
125
|
+
}
|
|
126
|
+
// No finish_reason seen — fall through to non-streaming
|
|
127
|
+
return this.client.chat(this.messages, this.tools);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (err instanceof LlmError && !this.streamFallbackWarned) {
|
|
131
|
+
this.streamFallbackWarned = true;
|
|
132
|
+
ui.warn(`streaming unavailable (${err.message.slice(0, 120)}) — falling back to non-streaming`);
|
|
133
|
+
}
|
|
134
|
+
return this.client.chat(this.messages, this.tools);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
get buildCmd() {
|
|
138
|
+
return this.config.buildCmd ?? detectBuildCommand(this.config.workdir) ?? "echo BUILD";
|
|
139
|
+
}
|
|
140
|
+
/** Connect configured MCP stdio servers (if any) and merge their tools. */
|
|
141
|
+
async connectMcp() {
|
|
142
|
+
const servers = this.config.mcpServers ?? {};
|
|
143
|
+
if (Object.keys(servers).length === 0)
|
|
144
|
+
return;
|
|
145
|
+
if (this.config.permissionMode === "readonly") {
|
|
146
|
+
ui.warn("readonly mode: MCP tools are disabled (they bypass the permission system)");
|
|
147
|
+
}
|
|
148
|
+
this.mcp = new McpManager(servers);
|
|
149
|
+
await this.mcp.connectAll();
|
|
150
|
+
const mcpSpecs = this.mcp.toToolSpecs();
|
|
151
|
+
if (mcpSpecs.length > 0) {
|
|
152
|
+
this.tools = [...AGENT_TOOLS.map((t) => t.spec), ...mcpSpecs];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Disconnect MCP servers at the end of a run. */
|
|
156
|
+
shutdownMcp() {
|
|
157
|
+
this.mcp?.killAll();
|
|
158
|
+
this.mcp = null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Autonomous closed loop:
|
|
162
|
+
* task -> LLM plans -> tool calls -> build -> on failure: read error, patch, retry.
|
|
163
|
+
*/
|
|
164
|
+
async run(task) {
|
|
165
|
+
if (!this.config.apiKey) {
|
|
166
|
+
throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
|
|
167
|
+
}
|
|
168
|
+
const buildCmd = this.buildCmd;
|
|
169
|
+
ui.header("TASK");
|
|
170
|
+
ui.info(task);
|
|
171
|
+
ui.info(`model=${this.client.model} workdir=${this.config.workdir} build="${buildCmd}" maxSteps=${this.config.maxSteps}`);
|
|
172
|
+
this.currentTask = task;
|
|
173
|
+
this.resetRunSignal();
|
|
174
|
+
this.messages = [
|
|
175
|
+
{
|
|
176
|
+
role: "system",
|
|
177
|
+
content: SYSTEM_PROMPT +
|
|
178
|
+
`\n\nWorking directory: ${this.config.workdir}\nBuild command (stop condition): ${buildCmd}`,
|
|
179
|
+
},
|
|
180
|
+
{ role: "user", content: task },
|
|
181
|
+
];
|
|
182
|
+
try {
|
|
183
|
+
await this.connectMcp();
|
|
184
|
+
return await this.runLoop(0, buildCmd, null);
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
this.shutdownMcp();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* One conversational turn in an ongoing session (used by the REPL):
|
|
192
|
+
* appends the task to the existing message history instead of resetting it,
|
|
193
|
+
* then runs the autonomous loop. First call behaves like run().
|
|
194
|
+
*/
|
|
195
|
+
async runTurn(task) {
|
|
196
|
+
if (this.messages.length === 0) {
|
|
197
|
+
return this.run(task);
|
|
198
|
+
}
|
|
199
|
+
const buildCmd = this.buildCmd;
|
|
200
|
+
this.currentTask = `${this.currentTask}\n${task}`.trim();
|
|
201
|
+
this.resetRunSignal();
|
|
202
|
+
this.messages.push({ role: "user", content: task });
|
|
203
|
+
try {
|
|
204
|
+
await this.connectMcp();
|
|
205
|
+
return await this.runLoop(0, buildCmd, null);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
this.shutdownMcp();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Resume a previous session: restore messages/step count and continue the loop.
|
|
213
|
+
* An optional follow-up task is appended as a new user message.
|
|
214
|
+
*/
|
|
215
|
+
async resume(session, followUp) {
|
|
216
|
+
const buildCmd = this.buildCmd;
|
|
217
|
+
this.sessionCreatedAt = session.createdAt;
|
|
218
|
+
this.currentTask = session.task;
|
|
219
|
+
this.resetRunSignal();
|
|
220
|
+
this.messages = session.messages ?? [];
|
|
221
|
+
ui.header(`RESUME ${session.id}`);
|
|
222
|
+
ui.info(`previous task: ${session.task} (${session.steps} step(s) done)`);
|
|
223
|
+
if (followUp) {
|
|
224
|
+
this.messages.push({ role: "user", content: followUp });
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
this.messages.push({
|
|
228
|
+
role: "user",
|
|
229
|
+
content: "Continue working on the original task. Build must pass before you finish.",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const lastBuild = session.lastBuild
|
|
233
|
+
? { success: session.lastBuild.success, command: session.lastBuild.command, output: session.lastBuild.output }
|
|
234
|
+
: null;
|
|
235
|
+
try {
|
|
236
|
+
await this.connectMcp();
|
|
237
|
+
return await this.runLoop(session.steps ?? 0, buildCmd, lastBuild);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
this.shutdownMcp();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The shared autonomous loop:
|
|
245
|
+
* LLM plans -> tool calls -> build -> on failure: read error, patch, retry.
|
|
246
|
+
*/
|
|
247
|
+
async runLoop(startSteps, buildCmd, initialBuild = null) {
|
|
248
|
+
let steps = startSteps;
|
|
249
|
+
let lastBuild = initialBuild;
|
|
250
|
+
while (steps < this.config.maxSteps && !this.aborted) {
|
|
251
|
+
steps++;
|
|
252
|
+
ui.step(steps, this.config.maxSteps, "thinking...");
|
|
253
|
+
const { message, usage } = await this.chatWithStream().catch((err) => {
|
|
254
|
+
// SIGINT during the LLM call — stop gracefully instead of crashing
|
|
255
|
+
if (this.aborted) {
|
|
256
|
+
return {
|
|
257
|
+
message: { role: "assistant", content: null, tool_calls: undefined },
|
|
258
|
+
usage: undefined,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
});
|
|
263
|
+
if (this.aborted)
|
|
264
|
+
break;
|
|
265
|
+
if (usage) {
|
|
266
|
+
ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
|
|
267
|
+
this.logger.log({ type: "step", step: steps, usage });
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
this.logger.log({ type: "step", step: steps });
|
|
271
|
+
}
|
|
272
|
+
// No tool calls => assistant produced a text answer
|
|
273
|
+
if (!message.tool_calls || message.tool_calls.length === 0) {
|
|
274
|
+
const text = message.content ?? "";
|
|
275
|
+
this.messages.push({ role: "assistant", content: text });
|
|
276
|
+
// If the assistant says it's done without building, force a build check.
|
|
277
|
+
// If the assistant says it's done without building, force a build check.
|
|
278
|
+
if (claimsCompletion(text) && steps < this.config.maxSteps) {
|
|
279
|
+
ui.info("agent claims completion -> verifying with build");
|
|
280
|
+
lastBuild = await this.checkBuild(buildCmd);
|
|
281
|
+
if (lastBuild.success) {
|
|
282
|
+
return this.finish(true, steps, buildCmd, text);
|
|
283
|
+
}
|
|
284
|
+
this.messages.push({
|
|
285
|
+
role: "user",
|
|
286
|
+
content: `Build still FAILED after your claimed completion. Output:\n${summariseOutput(lastBuild.output)}\nFix and continue.`,
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
ui.llm("agent", text);
|
|
291
|
+
// Treat a plain text answer with no build as interim commentary; nudge loop.
|
|
292
|
+
this.messages.push({
|
|
293
|
+
role: "user",
|
|
294
|
+
content: "Continue working on the task. Use tools. Build must pass before you finish.",
|
|
295
|
+
});
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
this.messages.push({
|
|
299
|
+
role: "assistant",
|
|
300
|
+
content: message.content ?? null,
|
|
301
|
+
tool_calls: message.tool_calls,
|
|
302
|
+
});
|
|
303
|
+
// Execute each tool call (validate args before running)
|
|
304
|
+
const stepToolResults = new Map();
|
|
305
|
+
for (const tc of message.tool_calls) {
|
|
306
|
+
if (this.aborted)
|
|
307
|
+
break;
|
|
308
|
+
const tool = getTool(tc.name);
|
|
309
|
+
let args;
|
|
310
|
+
try {
|
|
311
|
+
args = tc.arguments ? JSON.parse(tc.arguments) : {};
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
this.messages.push({
|
|
315
|
+
role: "tool",
|
|
316
|
+
tool_call_id: tc.id,
|
|
317
|
+
name: tc.name,
|
|
318
|
+
content: `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`,
|
|
319
|
+
});
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
ui.toolCall(tc.name, args);
|
|
323
|
+
if (!tool && !(this.mcp?.hasTool(tc.name))) {
|
|
324
|
+
ui.toolResult(tc.name, false, "unknown tool");
|
|
325
|
+
this.logger.log({ type: "tool", step: steps, tool: tc.name, ok: false, error: "unknown tool" });
|
|
326
|
+
this.messages.push({
|
|
327
|
+
role: "tool",
|
|
328
|
+
tool_call_id: tc.id,
|
|
329
|
+
name: tc.name,
|
|
330
|
+
content: `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`,
|
|
331
|
+
});
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
let result;
|
|
335
|
+
try {
|
|
336
|
+
// Permission gate for shell commands
|
|
337
|
+
if (tc.name === "run_command" && typeof args.command === "string") {
|
|
338
|
+
const decision = this.permissions.classify(args.command);
|
|
339
|
+
if (decision.decision === "deny") {
|
|
340
|
+
result = {
|
|
341
|
+
ok: false,
|
|
342
|
+
output: `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`,
|
|
343
|
+
};
|
|
344
|
+
ui.toolResult(tc.name, false, result.output);
|
|
345
|
+
this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, denied: decision.reason });
|
|
346
|
+
this.messages.push({
|
|
347
|
+
role: "tool",
|
|
348
|
+
tool_call_id: tc.id,
|
|
349
|
+
name: tc.name,
|
|
350
|
+
content: result.output,
|
|
351
|
+
});
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (decision.decision === "ask") {
|
|
355
|
+
const allowed = await this.permissions.confirm(args.command);
|
|
356
|
+
if (!allowed) {
|
|
357
|
+
result = {
|
|
358
|
+
ok: false,
|
|
359
|
+
output: `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`,
|
|
360
|
+
};
|
|
361
|
+
ui.toolResult(tc.name, false, result.output);
|
|
362
|
+
this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, denied: "user" });
|
|
363
|
+
this.messages.push({
|
|
364
|
+
role: "tool",
|
|
365
|
+
tool_call_id: tc.id,
|
|
366
|
+
name: tc.name,
|
|
367
|
+
content: result.output,
|
|
368
|
+
});
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const t0 = Date.now();
|
|
374
|
+
// Route MCP tools first (they carry the mcp__ prefix, not in AGENT_TOOLS)
|
|
375
|
+
if (tool) {
|
|
376
|
+
result = await tool.run(this.ctx, args);
|
|
377
|
+
}
|
|
378
|
+
else if (this.config.permissionMode === "readonly") {
|
|
379
|
+
// MCP tools are not covered by the permission system (no command
|
|
380
|
+
// classification, no diff preview) — deny them entirely in readonly.
|
|
381
|
+
result = {
|
|
382
|
+
ok: false,
|
|
383
|
+
output: `ERROR: readonly mode — MCP tool "${tc.name}" is disabled. MCP tools bypass the built-in permission policy, so they cannot run in readonly mode.`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
const reg = this.mcp?.getTool(tc.name);
|
|
388
|
+
if (!reg) {
|
|
389
|
+
result = { ok: false, output: `ERROR: unknown tool "${tc.name}"` };
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
const r = await reg.conn.callTool(reg.toolName, args);
|
|
393
|
+
result = { ok: r.ok, output: r.output.length > 20_000 ? r.output.slice(0, 20_000) + "\n... [truncated]" : r.output };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: result.ok, elapsedMs: Date.now() - t0 });
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
result = { ok: false, output: `ERROR: ${err.message}` };
|
|
400
|
+
this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, error: err.message });
|
|
401
|
+
}
|
|
402
|
+
// User pressed [q]uit in an edit-confirmation prompt — stop the whole run.
|
|
403
|
+
if (this.permissions.abortRequested) {
|
|
404
|
+
this.aborted = true;
|
|
405
|
+
ui.warn("edit confirmation aborted by user ([q]uit) — stopping the run");
|
|
406
|
+
}
|
|
407
|
+
ui.toolResult(tc.name, result.ok, result.output);
|
|
408
|
+
stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
|
|
409
|
+
this.messages.push({
|
|
410
|
+
role: "tool",
|
|
411
|
+
tool_call_id: tc.id,
|
|
412
|
+
name: tc.name,
|
|
413
|
+
content: summariseOutput(result.output),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// After a build-command run, treat success as the terminal condition.
|
|
417
|
+
// Match the FULL build command (not just its first word): matching "npm"
|
|
418
|
+
// would falsely trigger on "npm install" mid-task and end the agent.
|
|
419
|
+
const buildCall = message.tool_calls.find((tc) => tc.name === "run_command" && tc.arguments.includes(buildCmd));
|
|
420
|
+
if (buildCall) {
|
|
421
|
+
// Reuse the tool result when the agent ran the exact build command —
|
|
422
|
+
// avoids executing the build a second time just to verify.
|
|
423
|
+
const result = stepToolResults.get(buildCall.id);
|
|
424
|
+
if (result && result.ok) {
|
|
425
|
+
return this.finish(true, steps, buildCmd, message.content ?? "Build passed.");
|
|
426
|
+
}
|
|
427
|
+
lastBuild = await this.checkBuild(buildCmd);
|
|
428
|
+
if (lastBuild.success) {
|
|
429
|
+
return this.finish(true, steps, buildCmd, message.content ?? "Build passed.");
|
|
430
|
+
}
|
|
431
|
+
this.messages.push({
|
|
432
|
+
role: "user",
|
|
433
|
+
content: `Build FAILED (stop condition not met). Error output:\n${summariseOutput(lastBuild.output)}\nAnalyze the errors, fix the code, and rebuild.`,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
this.messages = trimHistory(this.messages);
|
|
437
|
+
// Persist session state after every step (enables resume after crash/SIGINT)
|
|
438
|
+
this.saveSession(steps, buildCmd, lastBuild);
|
|
439
|
+
}
|
|
440
|
+
// Max steps reached or aborted
|
|
441
|
+
const reason = this.aborted ? "interrupted by user (SIGINT)" : `reached max steps (${this.config.maxSteps})`;
|
|
442
|
+
this.saveSession(steps, buildCmd, lastBuild);
|
|
443
|
+
return this.finish(false, steps, buildCmd, `Agent stopped: ${reason}`);
|
|
444
|
+
}
|
|
445
|
+
/** Persist current agent state to .scoder/sessions/<id>.json. */
|
|
446
|
+
saveSession(steps, buildCmd, lastBuild) {
|
|
447
|
+
this.lastBuildResult = lastBuild;
|
|
448
|
+
this.session.save({
|
|
449
|
+
task: this.currentTask,
|
|
450
|
+
model: this.client.model,
|
|
451
|
+
provider: this.config.provider,
|
|
452
|
+
buildCmd,
|
|
453
|
+
steps,
|
|
454
|
+
maxSteps: this.config.maxSteps,
|
|
455
|
+
messages: this.messages,
|
|
456
|
+
lastBuild: lastBuild
|
|
457
|
+
? { success: lastBuild.success, command: lastBuild.command, output: lastBuild.output.slice(0, 10_000) }
|
|
458
|
+
: null,
|
|
459
|
+
}, this.sessionCreatedAt);
|
|
460
|
+
}
|
|
461
|
+
/** Explicitly verify the build (independent of what the LLM claims). */
|
|
462
|
+
async checkBuild(buildCmd) {
|
|
463
|
+
const res = await runBuild(this.config.workdir, buildCmd);
|
|
464
|
+
ui.buildStatus(res.success, res.output);
|
|
465
|
+
this.logger.log({ type: "build", success: res.success, command: buildCmd, output: res.output.slice(0, 10_000) });
|
|
466
|
+
return res;
|
|
467
|
+
}
|
|
468
|
+
finish(success, steps, buildCmd, summary) {
|
|
469
|
+
if (success) {
|
|
470
|
+
ui.success(`Task complete in ${steps} step(s). Build passed: ${buildCmd}`);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
ui.error(`Task NOT complete. ${summary}`);
|
|
474
|
+
}
|
|
475
|
+
this.logger.log({ type: "finish", success, steps });
|
|
476
|
+
return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
|
|
477
|
+
}
|
|
478
|
+
}
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { toolRunCommand } from "./tools.js";
|
|
4
|
+
/** Detect a sensible build command from project files in workdir. */
|
|
5
|
+
export function detectBuildCommand(workdir) {
|
|
6
|
+
const pkgPath = resolve(workdir, "package.json");
|
|
7
|
+
if (existsSync(pkgPath)) {
|
|
8
|
+
try {
|
|
9
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
10
|
+
if (pkg.scripts?.build)
|
|
11
|
+
return "npm run build";
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// fall through
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (existsSync(resolve(workdir, "Makefile")))
|
|
18
|
+
return "make";
|
|
19
|
+
if (existsSync(resolve(workdir, "go.mod")))
|
|
20
|
+
return "go build ./...";
|
|
21
|
+
if (existsSync(resolve(workdir, "Cargo.toml")))
|
|
22
|
+
return "cargo build";
|
|
23
|
+
if (existsSync(resolve(workdir, "pyproject.toml")))
|
|
24
|
+
return "python -m compileall .";
|
|
25
|
+
if (existsSync(resolve(workdir, "tsconfig.json")))
|
|
26
|
+
return "npx tsc --noEmit";
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Run the build command. This is the agent's stop condition:
|
|
31
|
+
* exit code 0 == task complete.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Run the build command. This is the agent's stop condition:
|
|
35
|
+
* exit code 0 == task complete.
|
|
36
|
+
*/
|
|
37
|
+
export async function runBuild(workdir, command) {
|
|
38
|
+
const res = await toolRunCommand(workdir, { command, timeout_secs: 600 });
|
|
39
|
+
return {
|
|
40
|
+
success: res.ok,
|
|
41
|
+
command,
|
|
42
|
+
output: res.output,
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
4
|
+
const DEFAULT_MODEL = "gpt-4o-mini";
|
|
5
|
+
const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
|
|
6
|
+
const ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-4-5";
|
|
7
|
+
function loadRcFile(workdir) {
|
|
8
|
+
for (const name of [".scoderrc.json", ".scoderrc"]) {
|
|
9
|
+
const p = resolve(workdir, name);
|
|
10
|
+
if (existsSync(p)) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// malformed rc file -> ignore
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
function parsePermissionMode(raw) {
|
|
22
|
+
if (raw === "auto" || raw === "ask" || raw === "readonly")
|
|
23
|
+
return raw;
|
|
24
|
+
return "auto";
|
|
25
|
+
}
|
|
26
|
+
function parseList(raw) {
|
|
27
|
+
if (!Array.isArray(raw))
|
|
28
|
+
return [];
|
|
29
|
+
return raw.filter((x) => typeof x === "string");
|
|
30
|
+
}
|
|
31
|
+
export function loadConfig(overrides = {}) {
|
|
32
|
+
const workdir = overrides.workdir ?? process.cwd();
|
|
33
|
+
const rc = loadRcFile(workdir);
|
|
34
|
+
// Provider auto-detection: explicit flag > env > rc > baseUrl/model heuristics
|
|
35
|
+
const providerRaw = overrides.provider ??
|
|
36
|
+
process.env.SCODER_PROVIDER ??
|
|
37
|
+
rc.provider ??
|
|
38
|
+
undefined;
|
|
39
|
+
const isAnthropic = providerRaw === "anthropic" ||
|
|
40
|
+
(providerRaw === undefined &&
|
|
41
|
+
/api\.anthropic\.com/.test(process.env.SCODER_BASE_URL ?? process.env.OPENAI_BASE_URL ?? rc.baseUrl ?? "")) ||
|
|
42
|
+
(providerRaw === undefined &&
|
|
43
|
+
/^claude-/i.test(overrides.model ?? process.env.SCODER_MODEL ?? rc.model ?? ""));
|
|
44
|
+
const provider = providerRaw === "anthropic" || isAnthropic ? "anthropic" : "openai";
|
|
45
|
+
// Validate maxSteps: NaN/0/negative would silently break the agent loop
|
|
46
|
+
// (steps < NaN is always false → the loop never runs).
|
|
47
|
+
const rawMaxSteps = Number(process.env.SCODER_MAX_STEPS ?? rc.maxSteps ?? 40);
|
|
48
|
+
const validatedMaxSteps = Number.isFinite(rawMaxSteps) && rawMaxSteps >= 1 ? Math.floor(rawMaxSteps) : 40;
|
|
49
|
+
const cfg = {
|
|
50
|
+
provider,
|
|
51
|
+
baseUrl: overrides.baseUrl ?? process.env.SCODER_BASE_URL ?? process.env.OPENAI_BASE_URL ?? rc.baseUrl ?? (isAnthropic ? ANTHROPIC_BASE_URL : DEFAULT_BASE_URL),
|
|
52
|
+
apiKey: overrides.apiKey ??
|
|
53
|
+
process.env.SCODER_API_KEY ??
|
|
54
|
+
(isAnthropic ? process.env.ANTHROPIC_API_KEY : undefined) ??
|
|
55
|
+
process.env.OPENAI_API_KEY ??
|
|
56
|
+
rc.apiKey ??
|
|
57
|
+
"",
|
|
58
|
+
model: overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (isAnthropic ? ANTHROPIC_DEFAULT_MODEL : DEFAULT_MODEL),
|
|
59
|
+
workdir,
|
|
60
|
+
buildCmd: overrides.buildCmd ?? process.env.SCODER_BUILD_CMD ?? rc.buildCmd ?? null,
|
|
61
|
+
maxSteps: overrides.maxSteps ?? validatedMaxSteps,
|
|
62
|
+
maxTokens: 8192,
|
|
63
|
+
temperature: 0.2,
|
|
64
|
+
yes: overrides.yes ?? false,
|
|
65
|
+
noSave: overrides.noSave ?? process.env.SCODER_NO_SAVE === "1",
|
|
66
|
+
logFile: overrides.logFile ??
|
|
67
|
+
// "1" means auto-path (.scoder/logs/<session>.jsonl); any other non-empty
|
|
68
|
+
// value is treated as an explicit path. A bare string "1" would otherwise
|
|
69
|
+
// resolve to a file literally named "1".
|
|
70
|
+
(process.env.SCODER_LOG_FILE === "1"
|
|
71
|
+
? true
|
|
72
|
+
: process.env.SCODER_LOG_FILE || undefined),
|
|
73
|
+
mcpServers: rc.mcpServers,
|
|
74
|
+
stream: overrides.stream ?? (process.env.SCODER_STREAM !== "0" ? true : false),
|
|
75
|
+
permissionMode: overrides.permissionMode ??
|
|
76
|
+
parsePermissionMode(process.env.SCODER_PERMISSION ?? rc.permission ?? rc.permissions?.mode ?? "auto"),
|
|
77
|
+
};
|
|
78
|
+
// Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
|
|
79
|
+
if (rc.apiKey && !process.env.SCODER_NO_APIKEY_WARNING) {
|
|
80
|
+
console.error(`\n⚠ WARNING: API key found in .scoderrc.json — this file may be committed to git.\n` +
|
|
81
|
+
` Prefer the environment variable SCODER_API_KEY (or OPENAI_API_KEY) instead,\n` +
|
|
82
|
+
` and add .scoderrc.json to .gitignore. Suppress: SCODER_NO_APIKEY_WARNING=1\n`);
|
|
83
|
+
}
|
|
84
|
+
return cfg;
|
|
85
|
+
}
|
|
86
|
+
/** Extract permission lists from the rc file (loaded again here to avoid widening AgentConfig). */
|
|
87
|
+
export function loadPermissionLists(workdir) {
|
|
88
|
+
const rc = loadRcFile(workdir);
|
|
89
|
+
return {
|
|
90
|
+
allowCommands: parseList(rc.permissions?.allowCommands),
|
|
91
|
+
denyCommands: parseList(rc.permissions?.denyCommands),
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const MAX_MESSAGES = 120; // keep history under control for long runs
|
|
2
|
+
/**
|
|
3
|
+
* Trim message history to stay within token budget.
|
|
4
|
+
* Strategy: keep system prompt + last N messages, compact older user/assistant pairs.
|
|
5
|
+
*/
|
|
6
|
+
export function trimHistory(messages) {
|
|
7
|
+
if (messages.length <= MAX_MESSAGES)
|
|
8
|
+
return messages;
|
|
9
|
+
// Keep system + last MAX_MESSAGES-1
|
|
10
|
+
const system = messages[0].role === "system" ? messages[0] : null;
|
|
11
|
+
const rest = system ? messages.slice(1) : messages;
|
|
12
|
+
const trimmed = rest.slice(-(MAX_MESSAGES - 1));
|
|
13
|
+
if (system) {
|
|
14
|
+
return [system, ...trimmed];
|
|
15
|
+
}
|
|
16
|
+
return trimmed;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Strict heuristic: does the assistant's plain-text message claim the task is
|
|
20
|
+
* finished? Deliberately narrow — bare words like "done" or "finished" appear
|
|
21
|
+
* in almost any sentence ("I'm not done yet", "package.json is finished with"),
|
|
22
|
+
* which previously triggered spurious build checks. Requires an explicit
|
|
23
|
+
* completion phrase instead.
|
|
24
|
+
*/
|
|
25
|
+
export function claimsCompletion(text) {
|
|
26
|
+
return /\b(?:task\s+(?:is\s+)?(?:now\s+)?(?:complete[d]?|done|finished)|all\s+(?:done|finished)|work\s+is\s+(?:now\s+)?(?:complete[d]?|done|finished)|tests?\s+(?:all\s+)?pass(?:ed|ing)?\s*[-—]\s*(?:task|everything)\s+complete[d]?)\b/i.test(text);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Summarize a long output to keep context lean.
|
|
30
|
+
* For very long tool outputs, keep beginning + error lines + end.
|
|
31
|
+
*/
|
|
32
|
+
export function summariseOutput(output, maxChars = 4000) {
|
|
33
|
+
if (output.length <= maxChars)
|
|
34
|
+
return output;
|
|
35
|
+
const lines = output.split("\n");
|
|
36
|
+
if (lines.length <= 30)
|
|
37
|
+
return output.slice(0, maxChars) + "\n... [truncated]";
|
|
38
|
+
const keepStart = Math.min(10, Math.floor(lines.length * 0.1));
|
|
39
|
+
const keepEnd = Math.min(10, Math.floor(lines.length * 0.1));
|
|
40
|
+
const snippet = lines.slice(0, keepStart).join("\n") +
|
|
41
|
+
`\n... [${lines.length - keepStart - keepEnd} lines omitted] ...\n` +
|
|
42
|
+
lines.slice(-keepEnd).join("\n");
|
|
43
|
+
return snippet.slice(0, maxChars) + "\n... [truncated]";
|
|
44
|
+
}
|