s-app-scoder-agent 0.3.0 → 0.5.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/README.md +86 -20
- package/dist/agent.js +131 -49
- package/dist/build.js +6 -8
- package/dist/config.js +26 -2
- package/dist/constants.js +69 -0
- package/dist/cost.js +141 -0
- package/dist/index.js +73 -0
- package/dist/llm.js +11 -1
- package/dist/permissions.js +24 -3
- package/dist/provider-anthropic.js +14 -4
- package/dist/server.js +256 -0
- package/dist/tools.js +43 -6
- package/dist/ui.js +76 -10
- package/dist/webfetch.js +251 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -80,9 +80,22 @@ You (task) → LLM plans → Tools execute code → Build runs → Success? →
|
|
|
80
80
|
| `edit_file` | Find-and-replace (unique match required) |
|
|
81
81
|
| `list_files` | Recursive directory listing |
|
|
82
82
|
| `run_command` | Execute any shell command (subject to permission policy) |
|
|
83
|
+
| `web_fetch` | Fetch a public HTTP(S) URL → readable text (HTML→text, JSON pretty-printed) |
|
|
83
84
|
|
|
84
85
|
File writes (`write_file` / `edit_file`) are symlink-safe: paths are resolved via `realpath` and cannot escape the working directory, even through a malicious symlink. `read_file` refuses files larger than 2MB with guidance to use `grep`/`head`/`tail`.
|
|
85
86
|
|
|
87
|
+
### Web access (`web_fetch`)
|
|
88
|
+
|
|
89
|
+
The agent can read the internet through the built-in `web_fetch` tool — it fetches exact URLs (it cannot search) and returns readable plain text: HTML is converted to text (scripts/styles stripped, entities decoded, title extracted), JSON is pretty-printed, binary content is refused. The system prompt instructs the agent *when to fetch* (external/API docs, URLs in error messages, current versions, changelogs) and when not to (answer available locally).
|
|
90
|
+
|
|
91
|
+
Safety defaults:
|
|
92
|
+
|
|
93
|
+
- **SSRF protection** — `localhost`, private and link-local addresses (10.x, 172.16/12, 192.168.x, 127.x, 169.254.x, IPv6 ULA/link-local) are blocked. Set `SCODER_WEB_ALLOW_PRIVATE=1` to allow local dev servers.
|
|
94
|
+
- Only `http`/`https`; up to 5 redirects — every hop is re-validated against the private-address block list.
|
|
95
|
+
- 30 s timeout, SIGINT-abortable; body capped at 512 KB; extracted text capped at 20k chars (configurable via `max_chars`).
|
|
96
|
+
- HTML is converted to plain text (no JS execution); images/archives/PDFs are refused.
|
|
97
|
+
- Read-only — the tool never writes locally, so it is allowed even in `readonly` permission mode.
|
|
98
|
+
|
|
86
99
|
## Configuration
|
|
87
100
|
|
|
88
101
|
### Environment variables
|
|
@@ -96,6 +109,9 @@ File writes (`write_file` / `edit_file`) are symlink-safe: paths are resolved vi
|
|
|
96
109
|
| `SCODER_MAX_STEPS` | `40` | Max loop iterations before giving up |
|
|
97
110
|
| `SCODER_PERMISSION` | `auto` | Permission mode: `auto` \| `ask` \| `readonly` |
|
|
98
111
|
| `SCODER_STREAM` | `1` | `0` to disable streaming LLM output |
|
|
112
|
+
| `SCODER_STREAM` | `1` | `0` to disable streaming LLM output |
|
|
113
|
+
| `SCODER_SERVER_TOKEN` | — | `scoder serve` only: require `Authorization: Bearer <token>` |
|
|
114
|
+
| `SCODER_WEB_ALLOW_PRIVATE` | — | Set to `1` to let `web_fetch` reach localhost/private addresses (local dev) |
|
|
99
115
|
| `SCODER_NO_APIKEY_WARNING` | — | Set to `1` to suppress the apiKey-in-rc warning |
|
|
100
116
|
|
|
101
117
|
### Per-project config file (.scoderrc.json)
|
|
@@ -225,7 +241,39 @@ Sessions older than 7 days are pruned automatically on the next run. Use `--no-s
|
|
|
225
241
|
|
|
226
242
|
## Streaming
|
|
227
243
|
|
|
228
|
-
|
|
244
|
+
scoder streams everything it can, live, as it happens:
|
|
245
|
+
|
|
246
|
+
- **LLM tokens** — assistant text renders character-by-character (OpenAI-compatible SSE and native Anthropic streaming). If a provider doesn't support streaming, scoder automatically falls back to non-streaming requests. Disable with `SCODER_STREAM=0`.
|
|
247
|
+
- **Tool output** — `run_command` and build output print live (dimmed block) while the command runs, instead of appearing only after it finishes. Live output is capped at 20k chars; the full output still goes to the LLM.
|
|
248
|
+
- **SIGINT-safe** — Ctrl+C kills the running command immediately; streamed rendering never blocks the event loop.
|
|
249
|
+
- Output honors `NO_COLOR` and degrades to plain text when piped (CI-friendly).
|
|
250
|
+
|
|
251
|
+
## HTTP Server Mode (`scoder serve`)
|
|
252
|
+
|
|
253
|
+
Run scoder as a local HTTP service — submit tasks programmatically and consume progress as Server-Sent Events:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
scoder serve # http://127.0.0.1:0 (random port, printed at startup)
|
|
257
|
+
scoder serve --port 4114 # fixed port
|
|
258
|
+
SCODER_SERVER_TOKEN=secret scoder serve --host 127.0.0.1
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
| Endpoint | Method | Description |
|
|
262
|
+
|----------|--------|-------------|
|
|
263
|
+
| `/api/health` | GET | Liveness: `{ ok, running, runs }` |
|
|
264
|
+
| `/api/runs` | POST | Start a run: `{ "task": "...", "workdir?", "buildCmd?", "model?", "provider?", "maxSteps?", "permission?" }` → `202 { id, eventsUrl }` |
|
|
265
|
+
| `/api/runs` | GET | List runs with status/timing |
|
|
266
|
+
| `/api/runs/<id>` | GET | Run status + final result |
|
|
267
|
+
| `/api/runs/<id>/events` | GET | **SSE stream** — full event replay, then live, closes on finish |
|
|
268
|
+
| `/api/runs/<id>/abort` | POST | Abort a running run (equivalent to SIGINT) |
|
|
269
|
+
|
|
270
|
+
SSE event types: `run_start`, `step`, `llm_delta` (assistant text), `tool_call`, `tool_output` (live stdout/stderr chunks), `tool_result`, `llm_usage`, `build`, `finish`. The stream replays every event from the beginning, then stays live, and closes after `finish` — safe to (re)connect at any time.
|
|
271
|
+
|
|
272
|
+
Notes:
|
|
273
|
+
|
|
274
|
+
- Runs are always non-interactive; `permission: "ask"` is rejected — use `auto` or `readonly`.
|
|
275
|
+
- Binds `127.0.0.1` by default. Set `SCODER_SERVER_TOKEN` to require `Authorization: Bearer <token>` on every request.
|
|
276
|
+
- Max 4 concurrent runs (extra requests get `429`); per-run overrides: `workdir`, `buildCmd`, `model`, `provider`, `maxSteps`, `permission`.
|
|
229
277
|
|
|
230
278
|
## Logging
|
|
231
279
|
|
|
@@ -268,6 +316,7 @@ scoder detects the build command automatically:
|
|
|
268
316
|
```
|
|
269
317
|
scoder [options] "<task>" Run a task autonomously until the build passes
|
|
270
318
|
scoder Start interactive session
|
|
319
|
+
scoder serve [--port] [--host] HTTP server mode (see above)
|
|
271
320
|
scoder --help | --version Help and version
|
|
272
321
|
```
|
|
273
322
|
|
|
@@ -283,6 +332,9 @@ scoder --help | --version Help and version
|
|
|
283
332
|
| `-y` | Non-interactive mode (denies commands needing confirmation) |
|
|
284
333
|
| `--provider <name>` | `openai` \| `anthropic` (env SCODER_PROVIDER; auto-detected) |
|
|
285
334
|
| `--no-save` | Disable session auto-save to `.scoder/sessions/` |
|
|
335
|
+
| `--log-file` | Write a structured JSONL event log to `.scoder/logs/` |
|
|
336
|
+
| `--port <n>` | (`serve`) TCP port, `0` = random free port |
|
|
337
|
+
| `--host <addr>` | (`serve`) Bind address (default `127.0.0.1`) |
|
|
286
338
|
|
|
287
339
|
## Examples
|
|
288
340
|
|
|
@@ -305,6 +357,15 @@ scoder -m claude-sonnet-4-5 "refactor the parser module"
|
|
|
305
357
|
|
|
306
358
|
# CI: non-interactive, no session saving
|
|
307
359
|
scoder --no-save -y "run the full migration"
|
|
360
|
+
|
|
361
|
+
# Run as an HTTP service and consume progress via SSE
|
|
362
|
+
scoder serve --port 4114 &
|
|
363
|
+
curl -s -X POST localhost:4114/api/runs -H 'Content-Type: application/json' \
|
|
364
|
+
-d '{"task": "add input validation to the CLI"}'
|
|
365
|
+
curl -N http://127.0.0.1:4114/api/runs/<id>/events # live progress
|
|
366
|
+
|
|
367
|
+
# Fetch external docs during a task (agent decides when, via web_fetch)
|
|
368
|
+
scoder "upgrade express to v5 — check the official migration guide first"
|
|
308
369
|
```
|
|
309
370
|
|
|
310
371
|
## Architecture
|
|
@@ -312,22 +373,26 @@ scoder --no-save -y "run the full migration"
|
|
|
312
373
|
```
|
|
313
374
|
┌─────────────────────────────────────────────┐
|
|
314
375
|
│ CLI (src/index.ts) │
|
|
315
|
-
│ arg parsing, REPL, SIGINT
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
376
|
+
│ arg parsing, REPL, serve, SIGINT │
|
|
377
|
+
└───────────────┬─────────────────────────────┘
|
|
378
|
+
│
|
|
379
|
+
┌───────────────▼─────────────────────────────┐
|
|
319
380
|
│ Agent (src/agent.ts) │
|
|
320
|
-
│
|
|
321
|
-
│
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
│
|
|
327
|
-
│
|
|
328
|
-
|
|
329
|
-
│
|
|
330
|
-
|
|
381
|
+
│ Closed-loop orchestrator │
|
|
382
|
+
│ history trimming, error injection │
|
|
383
|
+
│ emits AgentEvent (UI / SSE consumers) │
|
|
384
|
+
└──────┬─────────────┬───────────────┬────────┘
|
|
385
|
+
│ │ │
|
|
386
|
+
┌──────▼───────┐ ┌──▼───────────┐ ┌▼──────────────────┐
|
|
387
|
+
│ LLM Client │ │ Tool Registry│ │ Server │
|
|
388
|
+
│ (src/llm.ts) │ │(src/tools.ts)│ │ (src/server.ts) │
|
|
389
|
+
│ OpenAI compat│ │ + web_fetch │ │ HTTP API + SSE │
|
|
390
|
+
│ (anthropic: │ │ (src/ │ │ runs in background│
|
|
391
|
+
│ provider- │ │ webfetch.ts)│ └───────────────────┘
|
|
392
|
+
│ anthropic) │ └──────────────┘
|
|
393
|
+
└──────────────┘
|
|
394
|
+
│
|
|
395
|
+
▼
|
|
331
396
|
┌─────────────────────────────────────────────┐
|
|
332
397
|
│ Build Checker (src/build.ts) │
|
|
333
398
|
│ run build → exit 0 = stop condition met │
|
|
@@ -337,7 +402,7 @@ scoder --no-save -y "run the full migration"
|
|
|
337
402
|
## Integration Testing
|
|
338
403
|
|
|
339
404
|
```bash
|
|
340
|
-
npm test # runs:
|
|
405
|
+
npm test # runs: build → unit tests (node --test) → e2e closed-loop → MCP smoke
|
|
341
406
|
npm run lint # eslint flat config (typescript-eslint + eqeqeq, prefer-const...)
|
|
342
407
|
npm run typecheck # tsc --noEmit
|
|
343
408
|
```
|
|
@@ -345,17 +410,18 @@ npm run typecheck # tsc --noEmit
|
|
|
345
410
|
Test suites (run with `npm test` — auto-discovers `test/*.test.mjs`):
|
|
346
411
|
- `test/permissions.test.mjs` — catastrophic patterns, mode classification, allowlist word-boundary, timeout-denies
|
|
347
412
|
- `test/permission-prompt.test.mjs` — interactive prompt timeout behavior
|
|
348
|
-
- `test/tools.test.mjs` — safePath traversal, symlink escape, file-size cap, write/edit/list
|
|
413
|
+
- `test/tools.test.mjs` — safePath traversal, symlink escape, file-size cap, write/edit/list, live `run_command` output streaming
|
|
414
|
+
- `test/webfetch.test.mjs` — HTML→text, JSON, redirects, SSRF guard, binary refusal, truncation
|
|
415
|
+
- `test/server.test.mjs` — HTTP API: run lifecycle via SSE, abort, auth, live `tool_output` events
|
|
349
416
|
- `test/core.test.mjs` — unified diff, retry backoff, context trimming
|
|
350
417
|
- `test/config.test.mjs` — env/rc/cli precedence, logFile mapping, permission lists
|
|
351
418
|
- `test/session.test.mjs` — session store, smart prune, resolve
|
|
419
|
+
- `test/cost.test.mjs` — token usage & cost aggregation
|
|
352
420
|
- `test/anthropic.test.mjs` — message translation + native SSE streaming
|
|
353
421
|
- `test/llm.test.mjs` — OpenAI-compatible chat + SSE streaming + redaction
|
|
354
422
|
- `test/run-e2e.mjs` — closed-loop integration (mock LLM server)
|
|
355
423
|
- `test/run-mcp-test.mjs` — MCP smoke (mock stdio server)
|
|
356
424
|
|
|
357
|
-
Coverage (lines): **90.3%** overall — see `npm run coverage`.
|
|
358
|
-
|
|
359
425
|
## Exit Codes
|
|
360
426
|
|
|
361
427
|
| Code | Meaning |
|
package/dist/agent.js
CHANGED
|
@@ -28,7 +28,13 @@ RULES:
|
|
|
28
28
|
6. Never invent requirements beyond the given task.
|
|
29
29
|
7. IMPORTANT: if a run_command is denied by permission policy, you will receive
|
|
30
30
|
an error message. Use this as a signal to find a safer alternative or to
|
|
31
|
-
communicate the limitation to the user
|
|
31
|
+
communicate the limitation to the user.
|
|
32
|
+
8. You have web access ONLY through web_fetch (it fetches exact URLs, it cannot
|
|
33
|
+
search). Use it when the task depends on external or fast-changing knowledge:
|
|
34
|
+
library/API documentation, a URL found in an error message or code comment,
|
|
35
|
+
current package versions, changelogs, release notes, standards. Skip it when
|
|
36
|
+
the answer is in the working directory or is stable general knowledge. After
|
|
37
|
+
fetching, ground your edits in what the page actually says.`;
|
|
32
38
|
export class Agent {
|
|
33
39
|
config;
|
|
34
40
|
client;
|
|
@@ -43,9 +49,15 @@ export class Agent {
|
|
|
43
49
|
sessionCreatedAt;
|
|
44
50
|
currentTask = "";
|
|
45
51
|
lastBuildResult = null;
|
|
52
|
+
/** Accumulated token usage across the current run (session state). */
|
|
53
|
+
usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
|
|
46
54
|
mcp = null;
|
|
47
55
|
/** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
|
|
48
56
|
runSignal = new AbortController();
|
|
57
|
+
/** Optional structured-event sink (used by the HTTP/SSE server frontend). */
|
|
58
|
+
onEvent = null;
|
|
59
|
+
/** Set while a tool call is executing, for tool_output event correlation. */
|
|
60
|
+
currentToolCall = null;
|
|
49
61
|
constructor(config) {
|
|
50
62
|
this.config = config;
|
|
51
63
|
const commonOpts = {
|
|
@@ -70,6 +82,7 @@ export class Agent {
|
|
|
70
82
|
workdir: config.workdir,
|
|
71
83
|
permissions: this.permissions,
|
|
72
84
|
permissionMode: config.permissionMode,
|
|
85
|
+
onToolOutput: this.handleToolOutput,
|
|
73
86
|
};
|
|
74
87
|
this.session = new SessionStore(config.workdir, config.noSave !== true);
|
|
75
88
|
this.logger = JsonlLogger.forSession(config.workdir, this.session.id, config.logFile);
|
|
@@ -90,6 +103,81 @@ export class Agent {
|
|
|
90
103
|
// terminate its child process immediately (long builds must be interruptible).
|
|
91
104
|
this.ctx.signal = this.runSignal.signal;
|
|
92
105
|
}
|
|
106
|
+
/** Emit one structured run event to the sink (if attached). */
|
|
107
|
+
emit(ev) {
|
|
108
|
+
this.onEvent?.(ev);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Live tool-output handler: renders chunks in the terminal (dimmed block) and
|
|
112
|
+
* forwards them as `tool_output` events. Opens the stream block lazily on the
|
|
113
|
+
* first chunk; `endToolStream()` closes it after the tool completes.
|
|
114
|
+
*/
|
|
115
|
+
handleToolOutput = (name, stream, delta) => {
|
|
116
|
+
if (!ui.isToolStreamOpen()) {
|
|
117
|
+
ui.toolStreamStart();
|
|
118
|
+
}
|
|
119
|
+
ui.toolStreamChunk(delta);
|
|
120
|
+
this.emit({ type: "tool_output", name, stream, delta });
|
|
121
|
+
};
|
|
122
|
+
/** Close the live tool-output block (if open) before printing the next UI element. */
|
|
123
|
+
endToolStream() {
|
|
124
|
+
if (ui.isToolStreamOpen())
|
|
125
|
+
ui.toolStreamEnd();
|
|
126
|
+
}
|
|
127
|
+
/** Push one tool-result message back into the conversation. */
|
|
128
|
+
pushToolResult(tc, content) {
|
|
129
|
+
this.messages.push({
|
|
130
|
+
role: "tool",
|
|
131
|
+
tool_call_id: tc.id,
|
|
132
|
+
name: tc.name,
|
|
133
|
+
content,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Handle a rejected tool call (unknown tool, policy denial, user denial):
|
|
138
|
+
* render the failure, log the event, and push an ERROR tool message so the
|
|
139
|
+
* LLM can react. Replaces the previously duplicated denied/deny/unknown blocks.
|
|
140
|
+
*/
|
|
141
|
+
rejectToolCall(step, tc, output, logExtra) {
|
|
142
|
+
ui.toolResult(tc.name, false, output);
|
|
143
|
+
this.emit({ type: "tool_result", step, name: tc.name, ok: false, output });
|
|
144
|
+
this.logger.log({ type: "tool", step, tool: tc.name, ok: false, ...logExtra });
|
|
145
|
+
this.pushToolResult(tc, output);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Record usage of one LLM call: render token counts, log an `llm_usage`
|
|
149
|
+
* event, and accumulate totals into session state.
|
|
150
|
+
*/
|
|
151
|
+
recordLlmUsage(usage) {
|
|
152
|
+
if (!usage)
|
|
153
|
+
return;
|
|
154
|
+
const input = usage.prompt_tokens ?? 0;
|
|
155
|
+
const output = usage.completion_tokens ?? 0;
|
|
156
|
+
const cached = usage.cached_tokens ?? 0;
|
|
157
|
+
this.usageTotals.calls += 1;
|
|
158
|
+
this.usageTotals.inputTokens += input;
|
|
159
|
+
this.usageTotals.outputTokens += output;
|
|
160
|
+
this.usageTotals.cachedTokens += cached;
|
|
161
|
+
ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
|
|
162
|
+
this.emit({
|
|
163
|
+
type: "llm_usage",
|
|
164
|
+
usage: { input_tokens: input, output_tokens: output, cached_tokens: cached },
|
|
165
|
+
});
|
|
166
|
+
this.logger.log({
|
|
167
|
+
type: "llm_usage",
|
|
168
|
+
model: this.client.model,
|
|
169
|
+
provider: this.config.provider,
|
|
170
|
+
usage: {
|
|
171
|
+
input_tokens: input,
|
|
172
|
+
output_tokens: output,
|
|
173
|
+
cached_tokens: cached,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** Reset per-run usage accumulation (called when a fresh run starts). */
|
|
178
|
+
resetUsageTotals() {
|
|
179
|
+
this.usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
|
|
180
|
+
}
|
|
93
181
|
/**
|
|
94
182
|
* One LLM turn: streaming if enabled (renders deltas live), with automatic
|
|
95
183
|
* fallback to non-streaming when the provider/stream fails.
|
|
@@ -110,6 +198,7 @@ export class Agent {
|
|
|
110
198
|
started = true;
|
|
111
199
|
}
|
|
112
200
|
ui.llmStreamChunk(chunk.contentDelta);
|
|
201
|
+
this.emit({ type: "llm_delta", delta: chunk.contentDelta });
|
|
113
202
|
streamedText += chunk.contentDelta;
|
|
114
203
|
}
|
|
115
204
|
if (chunk.final)
|
|
@@ -166,11 +255,16 @@ export class Agent {
|
|
|
166
255
|
throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
|
|
167
256
|
}
|
|
168
257
|
const buildCmd = this.buildCmd;
|
|
169
|
-
ui.
|
|
170
|
-
|
|
171
|
-
|
|
258
|
+
ui.banner("scoder", [
|
|
259
|
+
["task", task],
|
|
260
|
+
["model", `${this.client.model} (${this.config.provider}${this.config.stream ? ", stream" : ""})`],
|
|
261
|
+
["workdir", this.config.workdir],
|
|
262
|
+
["build", buildCmd],
|
|
263
|
+
["steps", `max ${this.config.maxSteps} · permission ${this.config.permissionMode}`],
|
|
264
|
+
]);
|
|
172
265
|
this.currentTask = task;
|
|
173
266
|
this.resetRunSignal();
|
|
267
|
+
this.resetUsageTotals();
|
|
174
268
|
this.messages = [
|
|
175
269
|
{
|
|
176
270
|
role: "system",
|
|
@@ -179,6 +273,14 @@ export class Agent {
|
|
|
179
273
|
},
|
|
180
274
|
{ role: "user", content: task },
|
|
181
275
|
];
|
|
276
|
+
this.emit({
|
|
277
|
+
type: "run_start",
|
|
278
|
+
task,
|
|
279
|
+
model: this.client.model,
|
|
280
|
+
provider: this.config.provider,
|
|
281
|
+
workdir: this.config.workdir,
|
|
282
|
+
buildCmd,
|
|
283
|
+
});
|
|
182
284
|
try {
|
|
183
285
|
await this.connectMcp();
|
|
184
286
|
return await this.runLoop(0, buildCmd, null);
|
|
@@ -199,6 +301,7 @@ export class Agent {
|
|
|
199
301
|
const buildCmd = this.buildCmd;
|
|
200
302
|
this.currentTask = `${this.currentTask}\n${task}`.trim();
|
|
201
303
|
this.resetRunSignal();
|
|
304
|
+
this.resetUsageTotals();
|
|
202
305
|
this.messages.push({ role: "user", content: task });
|
|
203
306
|
try {
|
|
204
307
|
await this.connectMcp();
|
|
@@ -217,6 +320,7 @@ export class Agent {
|
|
|
217
320
|
this.sessionCreatedAt = session.createdAt;
|
|
218
321
|
this.currentTask = session.task;
|
|
219
322
|
this.resetRunSignal();
|
|
323
|
+
this.resetUsageTotals();
|
|
220
324
|
this.messages = session.messages ?? [];
|
|
221
325
|
ui.header(`RESUME ${session.id}`);
|
|
222
326
|
ui.info(`previous task: ${session.task} (${session.steps} step(s) done)`);
|
|
@@ -250,6 +354,7 @@ export class Agent {
|
|
|
250
354
|
while (steps < this.config.maxSteps && !this.aborted) {
|
|
251
355
|
steps++;
|
|
252
356
|
ui.step(steps, this.config.maxSteps, "thinking...");
|
|
357
|
+
this.emit({ type: "step", step: steps, maxSteps: this.config.maxSteps });
|
|
253
358
|
const { message, usage } = await this.chatWithStream().catch((err) => {
|
|
254
359
|
// SIGINT during the LLM call — stop gracefully instead of crashing
|
|
255
360
|
if (this.aborted) {
|
|
@@ -262,19 +367,13 @@ export class Agent {
|
|
|
262
367
|
});
|
|
263
368
|
if (this.aborted)
|
|
264
369
|
break;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.logger.log({ type: "step", step: steps, usage });
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
this.logger.log({ type: "step", step: steps });
|
|
271
|
-
}
|
|
370
|
+
this.recordLlmUsage(usage);
|
|
371
|
+
this.logger.log({ type: "step", step: steps });
|
|
272
372
|
// No tool calls => assistant produced a text answer
|
|
273
373
|
if (!message.tool_calls || message.tool_calls.length === 0) {
|
|
274
374
|
const text = message.content ?? "";
|
|
275
375
|
this.messages.push({ role: "assistant", content: text });
|
|
276
376
|
// 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
377
|
if (claimsCompletion(text) && steps < this.config.maxSteps) {
|
|
279
378
|
ui.info("agent claims completion -> verifying with build");
|
|
280
379
|
lastBuild = await this.checkBuild(buildCmd);
|
|
@@ -311,24 +410,14 @@ export class Agent {
|
|
|
311
410
|
args = tc.arguments ? JSON.parse(tc.arguments) : {};
|
|
312
411
|
}
|
|
313
412
|
catch {
|
|
314
|
-
this.
|
|
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
|
-
});
|
|
413
|
+
this.pushToolResult(tc, `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`);
|
|
320
414
|
continue;
|
|
321
415
|
}
|
|
322
416
|
ui.toolCall(tc.name, args);
|
|
417
|
+
this.currentToolCall = { step: steps, id: tc.id, name: tc.name };
|
|
418
|
+
this.emit({ type: "tool_call", step: steps, id: tc.id, name: tc.name, args });
|
|
323
419
|
if (!tool && !(this.mcp?.hasTool(tc.name))) {
|
|
324
|
-
|
|
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
|
-
});
|
|
420
|
+
this.rejectToolCall(steps, tc, `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`, { error: "unknown tool" });
|
|
332
421
|
continue;
|
|
333
422
|
}
|
|
334
423
|
let result;
|
|
@@ -341,14 +430,7 @@ export class Agent {
|
|
|
341
430
|
ok: false,
|
|
342
431
|
output: `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`,
|
|
343
432
|
};
|
|
344
|
-
|
|
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
|
-
});
|
|
433
|
+
this.rejectToolCall(steps, tc, result.output, { args, denied: decision.reason });
|
|
352
434
|
continue;
|
|
353
435
|
}
|
|
354
436
|
if (decision.decision === "ask") {
|
|
@@ -358,14 +440,7 @@ export class Agent {
|
|
|
358
440
|
ok: false,
|
|
359
441
|
output: `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`,
|
|
360
442
|
};
|
|
361
|
-
|
|
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
|
-
});
|
|
443
|
+
this.rejectToolCall(steps, tc, result.output, { args, denied: "user" });
|
|
369
444
|
continue;
|
|
370
445
|
}
|
|
371
446
|
}
|
|
@@ -404,14 +479,12 @@ export class Agent {
|
|
|
404
479
|
this.aborted = true;
|
|
405
480
|
ui.warn("edit confirmation aborted by user ([q]uit) — stopping the run");
|
|
406
481
|
}
|
|
482
|
+
this.endToolStream();
|
|
483
|
+
this.currentToolCall = null;
|
|
407
484
|
ui.toolResult(tc.name, result.ok, result.output);
|
|
485
|
+
this.emit({ type: "tool_result", step: steps, name: tc.name, ok: result.ok, output: result.output });
|
|
408
486
|
stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
|
|
409
|
-
this.
|
|
410
|
-
role: "tool",
|
|
411
|
-
tool_call_id: tc.id,
|
|
412
|
-
name: tc.name,
|
|
413
|
-
content: summariseOutput(result.output),
|
|
414
|
-
});
|
|
487
|
+
this.pushToolResult(tc, summariseOutput(result.output));
|
|
415
488
|
}
|
|
416
489
|
// After a build-command run, treat success as the terminal condition.
|
|
417
490
|
// Match the FULL build command (not just its first word): matching "npm"
|
|
@@ -456,12 +529,20 @@ export class Agent {
|
|
|
456
529
|
lastBuild: lastBuild
|
|
457
530
|
? { success: lastBuild.success, command: lastBuild.command, output: lastBuild.output.slice(0, 10_000) }
|
|
458
531
|
: null,
|
|
532
|
+
usage: {
|
|
533
|
+
llmCalls: this.usageTotals.calls,
|
|
534
|
+
inputTokens: this.usageTotals.inputTokens,
|
|
535
|
+
outputTokens: this.usageTotals.outputTokens,
|
|
536
|
+
cachedTokens: this.usageTotals.cachedTokens,
|
|
537
|
+
},
|
|
459
538
|
}, this.sessionCreatedAt);
|
|
460
539
|
}
|
|
461
540
|
/** Explicitly verify the build (independent of what the LLM claims). */
|
|
462
541
|
async checkBuild(buildCmd) {
|
|
463
|
-
const res = await runBuild(this.config.workdir, buildCmd);
|
|
542
|
+
const res = await runBuild(this.config.workdir, buildCmd, (stream, delta) => this.handleToolOutput("build", stream, delta));
|
|
543
|
+
this.endToolStream();
|
|
464
544
|
ui.buildStatus(res.success, res.output);
|
|
545
|
+
this.emit({ type: "build", success: res.success, command: buildCmd, output: res.output });
|
|
465
546
|
this.logger.log({ type: "build", success: res.success, command: buildCmd, output: res.output.slice(0, 10_000) });
|
|
466
547
|
return res;
|
|
467
548
|
}
|
|
@@ -472,6 +553,7 @@ export class Agent {
|
|
|
472
553
|
else {
|
|
473
554
|
ui.error(`Task NOT complete. ${summary}`);
|
|
474
555
|
}
|
|
556
|
+
this.emit({ type: "finish", success, steps, summary: summary.slice(0, 2000) });
|
|
475
557
|
this.logger.log({ type: "finish", success, steps });
|
|
476
558
|
return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
|
|
477
559
|
}
|
package/dist/build.js
CHANGED
|
@@ -10,8 +10,9 @@ export function detectBuildCommand(workdir) {
|
|
|
10
10
|
if (pkg.scripts?.build)
|
|
11
11
|
return "npm run build";
|
|
12
12
|
}
|
|
13
|
-
catch {
|
|
14
|
-
// fall through
|
|
13
|
+
catch (err) {
|
|
14
|
+
// malformed package.json -> fall through to the other detectors
|
|
15
|
+
console.error(`⚠ cannot parse package.json (${err.message}) — build command detection degraded`);
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
if (existsSync(resolve(workdir, "Makefile")))
|
|
@@ -29,13 +30,10 @@ export function detectBuildCommand(workdir) {
|
|
|
29
30
|
/**
|
|
30
31
|
* Run the build command. This is the agent's stop condition:
|
|
31
32
|
* exit code 0 == task complete.
|
|
33
|
+
* `onOutput` (optional) receives stdout/stderr chunks live while the build runs.
|
|
32
34
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
* exit code 0 == task complete.
|
|
36
|
-
*/
|
|
37
|
-
export async function runBuild(workdir, command) {
|
|
38
|
-
const res = await toolRunCommand(workdir, { command, timeout_secs: 600 });
|
|
35
|
+
export async function runBuild(workdir, command, onOutput) {
|
|
36
|
+
const res = await toolRunCommand(workdir, { command, timeout_secs: 600 }, undefined, onOutput);
|
|
39
37
|
return {
|
|
40
38
|
success: res.ok,
|
|
41
39
|
command,
|
package/dist/config.js
CHANGED
|
@@ -11,8 +11,9 @@ function loadRcFile(workdir) {
|
|
|
11
11
|
try {
|
|
12
12
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
13
13
|
}
|
|
14
|
-
catch {
|
|
15
|
-
// malformed rc file -> ignore
|
|
14
|
+
catch (err) {
|
|
15
|
+
// malformed rc file -> ignore it, but tell the user why their config is not applied
|
|
16
|
+
console.error(`⚠ ignoring malformed config file ${name}: ${err.message}`);
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
}
|
|
@@ -28,6 +29,28 @@ function parseList(raw) {
|
|
|
28
29
|
return [];
|
|
29
30
|
return raw.filter((x) => typeof x === "string");
|
|
30
31
|
}
|
|
32
|
+
/** Validate the user-supplied pricing table; drop malformed entries with a warning. */
|
|
33
|
+
function parsePricing(raw) {
|
|
34
|
+
if (raw === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (typeof raw !== "object" || raw === null) {
|
|
37
|
+
console.error("⚠ ignoring invalid modelPricing in config (expected an object)");
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const key of Object.keys(raw)) {
|
|
42
|
+
const p = raw[key];
|
|
43
|
+
if (typeof p === "object" && p !== null &&
|
|
44
|
+
typeof p.input === "number" && Number.isFinite(p.input) &&
|
|
45
|
+
typeof p.output === "number" && Number.isFinite(p.output)) {
|
|
46
|
+
out[key] = { input: p.input, output: p.output };
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.error(`⚠ ignoring invalid modelPricing entry "${key}" (expected { input: number, output: number })`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
31
54
|
export function loadConfig(overrides = {}) {
|
|
32
55
|
const workdir = overrides.workdir ?? process.cwd();
|
|
33
56
|
const rc = loadRcFile(workdir);
|
|
@@ -74,6 +97,7 @@ export function loadConfig(overrides = {}) {
|
|
|
74
97
|
stream: overrides.stream ?? (process.env.SCODER_STREAM !== "0" ? true : false),
|
|
75
98
|
permissionMode: overrides.permissionMode ??
|
|
76
99
|
parsePermissionMode(process.env.SCODER_PERMISSION ?? rc.permission ?? rc.permissions?.mode ?? "auto"),
|
|
100
|
+
modelPricing: parsePricing(rc.modelPricing),
|
|
77
101
|
};
|
|
78
102
|
// Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
|
|
79
103
|
if (rc.apiKey && !process.env.SCODER_NO_APIKEY_WARNING) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central constants: model pricing for cost estimation.
|
|
3
|
+
*
|
|
4
|
+
* Prices are USD per 1M tokens (input / output), approximate list prices.
|
|
5
|
+
* They can be overridden per-project via the rc file:
|
|
6
|
+
*
|
|
7
|
+
* .scoderrc.json:
|
|
8
|
+
* { "modelPricing": { "my-model": { "input": 1.0, "output": 2.0 } } }
|
|
9
|
+
*
|
|
10
|
+
* Keys are matched as a PREFIX of the model name (case-insensitive), so
|
|
11
|
+
* "gpt-4o-mini" also covers "gpt-4o-mini-2024-07-18". More specific keys
|
|
12
|
+
* (longer prefix) win.
|
|
13
|
+
*/
|
|
14
|
+
/** Fallback price when a model is unknown (deliberately conservative, not zero). */
|
|
15
|
+
export const DEFAULT_MODEL_PRICING = { input: 0.5, output: 1.5 };
|
|
16
|
+
/** Approximate list prices, USD per 1M tokens. Longest matching prefix wins. */
|
|
17
|
+
export const MODEL_PRICING = {
|
|
18
|
+
// OpenAI
|
|
19
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
20
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
21
|
+
"gpt-4.1-mini": { input: 0.4, output: 1.6 },
|
|
22
|
+
"gpt-4.1": { input: 2, output: 8 },
|
|
23
|
+
"gpt-4-turbo": { input: 10, output: 30 },
|
|
24
|
+
"gpt-4": { input: 30, output: 60 },
|
|
25
|
+
"gpt-3.5-turbo": { input: 0.5, output: 1.5 },
|
|
26
|
+
"o1": { input: 15, output: 60 },
|
|
27
|
+
"o1-mini": { input: 1.1, output: 4.4 },
|
|
28
|
+
"o3-mini": { input: 1.1, output: 4.4 },
|
|
29
|
+
"o3": { input: 2, output: 8 },
|
|
30
|
+
"o4-mini": { input: 1.1, output: 4.4 },
|
|
31
|
+
// Anthropic
|
|
32
|
+
"claude-opus-4": { input: 15, output: 75 },
|
|
33
|
+
"claude-sonnet-4": { input: 3, output: 15 },
|
|
34
|
+
"claude-haiku-4": { input: 0.8, output: 4 },
|
|
35
|
+
"claude-3-7-sonnet": { input: 3, output: 15 },
|
|
36
|
+
"claude-3-5-sonnet": { input: 3, output: 15 },
|
|
37
|
+
"claude-3-5-haiku": { input: 0.8, output: 4 },
|
|
38
|
+
"claude-3-haiku": { input: 0.25, output: 1.25 },
|
|
39
|
+
"claude-3-opus": { input: 15, output: 75 },
|
|
40
|
+
// Popular local/OpenAI-compatible providers (mostly $0 or nominal)
|
|
41
|
+
"llama3": { input: 0, output: 0 },
|
|
42
|
+
"qwen": { input: 0, output: 0 },
|
|
43
|
+
"deepseek-chat": { input: 0.27, output: 1.1 },
|
|
44
|
+
"deepseek-reasoner": { input: 0.55, output: 2.19 },
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the price table for a model, honouring user overrides.
|
|
48
|
+
* `overrides` maps model-name prefixes to pricing and always wins over the
|
|
49
|
+
* built-in table. Longest matching prefix wins (case-insensitive).
|
|
50
|
+
*/
|
|
51
|
+
export function resolveModelPricing(model, overrides = {}) {
|
|
52
|
+
const m = model.toLowerCase();
|
|
53
|
+
let best = null;
|
|
54
|
+
// Built-in table first (longest prefix wins), then overrides — an override
|
|
55
|
+
// with the SAME prefix length replaces the built-in entry.
|
|
56
|
+
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
|
57
|
+
const k = key.toLowerCase();
|
|
58
|
+
if (m.startsWith(k) && (!best || k.length > best.key.length)) {
|
|
59
|
+
best = { key: k, pricing };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [key, pricing] of Object.entries(overrides)) {
|
|
63
|
+
const k = key.toLowerCase();
|
|
64
|
+
if (m.startsWith(k) && (!best || k.length >= best.key.length)) {
|
|
65
|
+
best = { key: k, pricing };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return best ? best.pricing : DEFAULT_MODEL_PRICING;
|
|
69
|
+
}
|