@hackerrank/astra-cli 0.1.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 +205 -0
- package/package.json +30 -0
- package/src/agent.js +319 -0
- package/src/cli.js +314 -0
- package/src/config.js +135 -0
- package/src/environment.js +121 -0
- package/src/model.js +314 -0
- package/src/prices.js +60 -0
- package/src/prompts.js +91 -0
- package/src/repl.js +601 -0
- package/src/session.js +99 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HackerRank
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# astra
|
|
2
|
+
|
|
3
|
+
A minimal, **zero-dependency** AI coding agent for the **HackerRank AI Gateway**,
|
|
4
|
+
written in plain Node.js (ESM, Node ≥ 20).
|
|
5
|
+
|
|
6
|
+
astra is a small, self-contained agent that talks to the `hackerrank-ai-gateway` and
|
|
7
|
+
works with **every** model on it. It runs in two modes from one engine:
|
|
8
|
+
|
|
9
|
+
- an **interactive assistant** — a chat REPL that can read files and run commands
|
|
10
|
+
(with your approval), like a lightweight cline / claude-code;
|
|
11
|
+
- an **autonomous task runner** — give it a task and it works to completion on its own.
|
|
12
|
+
|
|
13
|
+
Every run is a resumable session, with exact token accounting and USD cost tracking.
|
|
14
|
+
|
|
15
|
+
## Why it's minimal
|
|
16
|
+
|
|
17
|
+
- **No dependencies.** Uses Node's built-in `fetch`, `spawn`, and `fs`. No install step.
|
|
18
|
+
- **Text-based action protocol.** The model returns one fenced ` ```bash ` block per
|
|
19
|
+
turn, so it works with *every* model on the gateway — no provider tool-calling required.
|
|
20
|
+
- **Linear history.** Every step just appends to the message list. The session *is* the
|
|
21
|
+
conversation; trivial to debug and inspect.
|
|
22
|
+
- **Stateless actions.** Each command runs in a fresh `bash -c` subshell, so runs are
|
|
23
|
+
easy to reason about and sandbox.
|
|
24
|
+
|
|
25
|
+
## Layout
|
|
26
|
+
|
|
27
|
+
| File | Role |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `src/model.js` | Gateway client (OpenAI-compatible `/chat/completions`). Handles retries, the `max_tokens` vs `max_completion_tokens` difference, and cost accounting. |
|
|
30
|
+
| `src/environment.js` | Runs one command per action in a fresh subshell, with a per-command timeout that kills the whole process group and output truncation. |
|
|
31
|
+
| `src/prompts.js` | System / instance / format-error / observation templates + a tiny `{{var}}` renderer, split into interactive vs autonomous rules. |
|
|
32
|
+
| `src/agent.js` | The turn-based engine: query → parse one command → execute → observe → repeat. Handles submission, step/time limits, and format errors. |
|
|
33
|
+
| `src/repl.js` | The interactive assistant loop: prompts, command approval, slash commands. |
|
|
34
|
+
| `src/session.js` | Resumable sessions stored under `~/.astra/sessions/`. |
|
|
35
|
+
| `src/config.js` | Dedicated `~/.astra/config.json`, credential resolution, interactive key prompt. |
|
|
36
|
+
| `src/prices.js` | Public list prices used to estimate cost for models that don't report it. |
|
|
37
|
+
| `src/cli.js` | Argument parsing, mode dispatch, and streaming output. |
|
|
38
|
+
|
|
39
|
+
## Setup
|
|
40
|
+
|
|
41
|
+
Node ≥ 20. Install from npm:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install -g @hackerrank/astra
|
|
45
|
+
|
|
46
|
+
# or without a global install
|
|
47
|
+
npx @hackerrank/astra -m claude-sonnet-5
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
From a git checkout of this repo you can also run the CLI directly, or link it:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
node src/cli.js -m claude-sonnet-5
|
|
54
|
+
npm link # then `astra` uses this checkout
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
You also need an API key for the gateway. The CLI resolves it in this order
|
|
58
|
+
(first hit wins):
|
|
59
|
+
|
|
60
|
+
1. `--api-key` flag
|
|
61
|
+
2. `ASTRA_GATEWAY_API_KEY` environment variable
|
|
62
|
+
3. `~/.astra/config.json` — astra's own config (written with `0600` perms)
|
|
63
|
+
4. **interactive prompt** when run in a terminal — offers to save the key to
|
|
64
|
+
`~/.astra/config.json` so future runs pick it up automatically
|
|
65
|
+
|
|
66
|
+
The first interactive run asks for the key and can remember it. In CI, pass the
|
|
67
|
+
key via flag or env var.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
astra has **two modes**, sharing one engine:
|
|
72
|
+
|
|
73
|
+
### Interactive assistant (no task)
|
|
74
|
+
|
|
75
|
+
A personal coding-assistant REPL. Commands need your approval by default; the
|
|
76
|
+
whole chat is saved as a resumable session.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
astra -m claude-sonnet-5 # start chatting
|
|
80
|
+
astra -m claude-sonnet-5 -y # auto-run commands (no prompts)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
In-REPL commands: `/help /exit /clear /history /tokens /yolo`.
|
|
84
|
+
|
|
85
|
+
### Autonomous task run
|
|
86
|
+
|
|
87
|
+
When you pass a task with `-t`/`-f`, astra appends the autonomous rules and runs
|
|
88
|
+
to completion, submitting via the sentinel when done.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# from an inline task
|
|
92
|
+
astra -m claude-sonnet-5 -t "Fix the failing test in ./app and submit."
|
|
93
|
+
|
|
94
|
+
# from a task file, running inside a specific repo
|
|
95
|
+
astra \
|
|
96
|
+
-m gpt-5.6-sol \
|
|
97
|
+
-f ./task/instruction.md \
|
|
98
|
+
-C ./task/repo \
|
|
99
|
+
-s 60 \
|
|
100
|
+
-o ./runs/run-01.traj.json
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Sessions
|
|
104
|
+
|
|
105
|
+
Every run (either mode) is saved under `~/.astra/sessions/<id>.json`.
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
astra --sessions # list saved sessions
|
|
109
|
+
astra --resume <id> # resume (continue an interactive chat,
|
|
110
|
+
# or inspect/continue an autonomous run)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Verified working models on the gateway include `claude-sonnet-5`, `claude-opus-5`,
|
|
114
|
+
`gpt-5.6-sol`, `gpt-5.6-terra`, `gemini-3.7-flash`, `grok-4.6`, `kimi-k3`, and others.
|
|
115
|
+
Some routes (e.g. `glm-5.2`) may return HTTP 402 when the underlying provider is out of
|
|
116
|
+
credits — that's a quota issue, not a bug.
|
|
117
|
+
|
|
118
|
+
### Options
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
-m, --model <id> Model id on the gateway (required unless --sessions)
|
|
122
|
+
-t, --task <text> Task text -> autonomous mode
|
|
123
|
+
-f, --task-file <path> Read task text from a file -> autonomous mode
|
|
124
|
+
-C, --cwd <path> Working directory for commands (default: cwd)
|
|
125
|
+
-o, --output <path> Also write trajectory JSON here (autonomous mode)
|
|
126
|
+
-s, --steps <n> Step limit (default: 40)
|
|
127
|
+
-w, --wall <seconds> Wall-clock limit (default: 0 = none)
|
|
128
|
+
--timeout <seconds> Per-command timeout (default: 60)
|
|
129
|
+
--max-output <n> Max chars of command output kept (default: 16000)
|
|
130
|
+
--base-url <url> Override gateway base URL
|
|
131
|
+
--api-key <key> Gateway API key (else env/config/prompt)
|
|
132
|
+
--resume <id> Resume a saved session
|
|
133
|
+
--sessions List saved sessions and exit
|
|
134
|
+
-y, --yolo Auto-run commands (always on in autonomous mode)
|
|
135
|
+
-q, --quiet Do not stream steps (autonomous mode)
|
|
136
|
+
-h, --help Show help
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How the loop works
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
system + instance prompt
|
|
143
|
+
│
|
|
144
|
+
▼
|
|
145
|
+
┌─► query model ──► parse ONE ```bash block ──► run in fresh subshell
|
|
146
|
+
│ ▲ │ (bad format) │
|
|
147
|
+
│ └── format-error ◄───┘ ▼
|
|
148
|
+
│ observe <returncode>/<output>
|
|
149
|
+
└──────────────────────────────────────────────────────┘
|
|
150
|
+
│ command echoes the submit sentinel
|
|
151
|
+
▼
|
|
152
|
+
Submitted
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
In interactive mode, a reply with **no** bash block is treated as a chat turn that hands
|
|
156
|
+
control back to you; in autonomous mode the loop keeps going until the task is submitted
|
|
157
|
+
or a limit is hit.
|
|
158
|
+
|
|
159
|
+
Termination reasons written to the session `info.exit_status`:
|
|
160
|
+
|
|
161
|
+
- `Submitted` — a command printed `COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` with exit code 0.
|
|
162
|
+
- `LimitsExceeded` — hit the step limit.
|
|
163
|
+
- `TimeExceeded` — hit the wall-clock limit.
|
|
164
|
+
- `RepeatedFormatError` — too many unparseable replies in a row.
|
|
165
|
+
- `ContextWindowExceeded` — the request exceeded the model's context window.
|
|
166
|
+
|
|
167
|
+
## Session / trajectory output
|
|
168
|
+
|
|
169
|
+
Each run is stored as JSON (`astra-1` format):
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"trajectory_format": "astra-1",
|
|
174
|
+
"info": {
|
|
175
|
+
"mode": "autonomous",
|
|
176
|
+
"exit_status": "Submitted",
|
|
177
|
+
"submission": "",
|
|
178
|
+
"n_steps": 2,
|
|
179
|
+
"model": "claude-sonnet-5",
|
|
180
|
+
"n_calls": 2,
|
|
181
|
+
"elapsed_seconds": 6,
|
|
182
|
+
"tokens": { "prompt": 3414, "completion": 208, "total": 3622, "last_context": 1361 },
|
|
183
|
+
"cost": { "usd": 0.0139, "source": "estimated", "reported_usd": 0, "estimated_usd": 0.0139 }
|
|
184
|
+
},
|
|
185
|
+
"messages": [ /* full linear history: system, user, assistant, ... , exit */ ]
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Cost & token tracking
|
|
190
|
+
|
|
191
|
+
Tokens are exact (from each response's `usage`). Cost is **hybrid**, tracking-only
|
|
192
|
+
(no limits):
|
|
193
|
+
|
|
194
|
+
- **Reported** — some routes (the OpenRouter-proxied models: `deepseek-v4-pro`,
|
|
195
|
+
`glm-5.2`, `grok-4.6`, `kimi-k3`, `qwen-3.8`) return an exact `usage.cost` in USD.
|
|
196
|
+
We use that number directly (`source: "reported"`).
|
|
197
|
+
- **Estimated** — native routes (Claude, GPT, Gemini) return no cost, so we estimate
|
|
198
|
+
from `src/prices.js`, a table of **public list prices** ($/1M tokens). These are
|
|
199
|
+
clearly marked `source: "estimated"` and shown with a `~` in the per-step line.
|
|
200
|
+
Edit `src/prices.js` in one place if you have the real gateway pricing.
|
|
201
|
+
|
|
202
|
+
A run that mixes both is tagged `source: "mixed"` with the split preserved in
|
|
203
|
+
`reported_usd` / `estimated_usd`.
|
|
204
|
+
|
|
205
|
+
Remote / Jenkins benchmarking lives in the sibling repo `astra-bench`, not here.
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hackerrank/astra-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"astra": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node src/cli.js",
|
|
19
|
+
"astra": "node src/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"registry": "https://registry.npmjs.org/",
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+ssh://git@github.com/interviewstreet/hrbench.git"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent loop — the whole brain of astra in one small file.
|
|
3
|
+
*
|
|
4
|
+
* One engine, two modes:
|
|
5
|
+
* - autonomous: run to completion; a missing/extra command is a format error;
|
|
6
|
+
* the SUBMIT sentinel ends the run. Used for --task / benchmark.
|
|
7
|
+
* - interactive: a reply with a bash block runs a command and loops; a reply
|
|
8
|
+
* with NO bash block is a chat turn that hands control back to
|
|
9
|
+
* the user. Used for the REPL.
|
|
10
|
+
*
|
|
11
|
+
* Turn model: `runTurn()` performs one model call and (if a command was issued)
|
|
12
|
+
* one execution. It returns a small status object so a driver can either keep
|
|
13
|
+
* looping (autonomous) or pause for user input (interactive).
|
|
14
|
+
*
|
|
15
|
+
* Termination (autonomous):
|
|
16
|
+
* - Submitted: a command outputs COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT (rc 0)
|
|
17
|
+
* - LimitsExceeded: step limit reached
|
|
18
|
+
* - TimeExceeded: wall-clock limit reached
|
|
19
|
+
* - RepeatedFormatError: too many unparseable replies in a row
|
|
20
|
+
* - ContextWindowExceeded: prompt no longer fits the model's context
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import {
|
|
26
|
+
buildSystemPrompt,
|
|
27
|
+
INSTANCE_TEMPLATE,
|
|
28
|
+
FORMAT_ERROR,
|
|
29
|
+
OBSERVATION_TEMPLATE,
|
|
30
|
+
render,
|
|
31
|
+
} from "./prompts.js";
|
|
32
|
+
import { ContextWindowError } from "./model.js";
|
|
33
|
+
|
|
34
|
+
const SUBMIT_SENTINEL = "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT";
|
|
35
|
+
|
|
36
|
+
export class Agent {
|
|
37
|
+
constructor(model, env, opts = {}) {
|
|
38
|
+
this.model = model;
|
|
39
|
+
this.env = env;
|
|
40
|
+
this.mode = opts.mode === "interactive" ? "interactive" : "autonomous";
|
|
41
|
+
this.stepLimit = opts.stepLimit ?? 40;
|
|
42
|
+
this.wallTimeLimitSeconds = opts.wallTimeLimitSeconds ?? 0;
|
|
43
|
+
this.maxConsecutiveFormatErrors = opts.maxConsecutiveFormatErrors ?? 3;
|
|
44
|
+
this.outputPath = opts.outputPath ?? null;
|
|
45
|
+
this.onEvent = opts.onEvent ?? (() => {});
|
|
46
|
+
this.onStep = opts.onStep ?? (() => {});
|
|
47
|
+
// Approval gate: return true to allow a command, false to skip it. Default
|
|
48
|
+
// allows everything (autonomous / --yolo). Interactive mode wires a prompt.
|
|
49
|
+
this.confirm = opts.confirm ?? (() => true);
|
|
50
|
+
this.messages = [];
|
|
51
|
+
this.nSteps = 0;
|
|
52
|
+
this.formatErrorStreak = 0;
|
|
53
|
+
this.startTime = Date.now();
|
|
54
|
+
// Exact context size (prompt tokens) reported by the most recent call.
|
|
55
|
+
this.lastContextTokens = 0;
|
|
56
|
+
this.exitStatus = null; // set once the run terminates
|
|
57
|
+
this.task = opts.task ?? "";
|
|
58
|
+
// Optional session metadata; when set, save() also writes the session file.
|
|
59
|
+
this.sessionId = opts.sessionId ?? null;
|
|
60
|
+
this.sessionCreated = opts.sessionCreated ?? new Date().toISOString();
|
|
61
|
+
this.title = opts.title ?? "";
|
|
62
|
+
this._saveSession = opts.saveSession ?? null; // (id, doc) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
templateVars(extra = {}) {
|
|
66
|
+
return {
|
|
67
|
+
...this.env.templateVars(),
|
|
68
|
+
task: this.task ?? "",
|
|
69
|
+
...extra,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
add(role, content, extra = {}) {
|
|
74
|
+
const msg = { role, content, extra };
|
|
75
|
+
this.messages.push(msg);
|
|
76
|
+
this.onEvent(msg);
|
|
77
|
+
this.save();
|
|
78
|
+
return msg;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Initialize a fresh conversation. If `task` is provided, seed the first user
|
|
83
|
+
* message with the task instructions (autonomous submission workflow).
|
|
84
|
+
*/
|
|
85
|
+
start(task) {
|
|
86
|
+
if (task != null) this.task = task;
|
|
87
|
+
this.messages = [];
|
|
88
|
+
const vars = this.templateVars();
|
|
89
|
+
this.add("system", buildSystemPrompt(this.mode, vars));
|
|
90
|
+
if (this.task) {
|
|
91
|
+
this.add("user", render(INSTANCE_TEMPLATE, vars));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Add a user chat message (interactive follow-ups). */
|
|
96
|
+
addUserMessage(text) {
|
|
97
|
+
this.add("user", text);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
apiMessages() {
|
|
101
|
+
return this.messages
|
|
102
|
+
.filter((m) => m.role === "system" || m.role === "user" || m.role === "assistant")
|
|
103
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Perform one turn: query the model, then optionally run one command.
|
|
108
|
+
* Returns one of:
|
|
109
|
+
* { kind: "chat" } model replied with no command
|
|
110
|
+
* { kind: "command", returncode } command ran, loop can continue
|
|
111
|
+
* { kind: "format_error" } unparseable (autonomous only)
|
|
112
|
+
* { kind: "exit", exit_status, submission } run terminated
|
|
113
|
+
*/
|
|
114
|
+
async runTurn() {
|
|
115
|
+
this.nSteps++;
|
|
116
|
+
|
|
117
|
+
// --- 1. Query model ---
|
|
118
|
+
let content, usage;
|
|
119
|
+
try {
|
|
120
|
+
({ content, usage } = await this.model.query(this.apiMessages()));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof ContextWindowError) {
|
|
123
|
+
return this.exit("ContextWindowExceeded", "");
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
this.lastContextTokens = usage.prompt_tokens;
|
|
128
|
+
this.add("assistant", content, { usage });
|
|
129
|
+
|
|
130
|
+
this.onStep({
|
|
131
|
+
step: this.nSteps,
|
|
132
|
+
stepLimit: this.stepLimit,
|
|
133
|
+
nCalls: this.model.nCalls,
|
|
134
|
+
usage,
|
|
135
|
+
contextTokens: usage.prompt_tokens,
|
|
136
|
+
totalPromptTokens: this.model.totalPromptTokens,
|
|
137
|
+
totalCompletionTokens: this.model.totalCompletionTokens,
|
|
138
|
+
costUsd: usage.cost_usd,
|
|
139
|
+
costKind: usage.cost_kind,
|
|
140
|
+
totalCostUsd: this.model.totalCostUsd,
|
|
141
|
+
elapsedSeconds: (Date.now() - this.startTime) / 1000,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- 2. Parse a command (may be absent) ---
|
|
145
|
+
let command;
|
|
146
|
+
try {
|
|
147
|
+
command = parseCommand(content);
|
|
148
|
+
this.formatErrorStreak = 0;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// In interactive mode, "no command" is a normal chat reply that yields
|
|
151
|
+
// the turn back to the user. Only autonomous mode treats it as an error.
|
|
152
|
+
if (this.mode === "interactive" && err.code === "NO_COMMAND") {
|
|
153
|
+
return { kind: "chat", content };
|
|
154
|
+
}
|
|
155
|
+
this.formatErrorStreak++;
|
|
156
|
+
if (
|
|
157
|
+
this.maxConsecutiveFormatErrors > 0 &&
|
|
158
|
+
this.formatErrorStreak >= this.maxConsecutiveFormatErrors
|
|
159
|
+
) {
|
|
160
|
+
return this.exit("RepeatedFormatError", "");
|
|
161
|
+
}
|
|
162
|
+
this.add("user", render(FORMAT_ERROR, { error: err.message }));
|
|
163
|
+
return { kind: "format_error", error: err.message };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- 3. Approval gate ---
|
|
167
|
+
const allowed = await this.confirm(command);
|
|
168
|
+
if (!allowed) {
|
|
169
|
+
this.add("user", "The user declined to run that command. Suggest an alternative or ask what to do instead.", {
|
|
170
|
+
command,
|
|
171
|
+
declined: true,
|
|
172
|
+
});
|
|
173
|
+
return { kind: "declined", command };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- 4. Execute ---
|
|
177
|
+
const output = await this.env.execute(command);
|
|
178
|
+
|
|
179
|
+
// --- 5. Submission sentinel ---
|
|
180
|
+
const submitted = checkSubmitted(output);
|
|
181
|
+
if (submitted != null) {
|
|
182
|
+
return this.exit("Submitted", submitted);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- 6. Observation ---
|
|
186
|
+
const obs = render(OBSERVATION_TEMPLATE, {
|
|
187
|
+
exception: output.exception_info ? `<exception>${output.exception_info}</exception>\n` : "",
|
|
188
|
+
returncode: output.returncode,
|
|
189
|
+
output: output.output,
|
|
190
|
+
});
|
|
191
|
+
this.add("user", obs, { command, returncode: output.returncode });
|
|
192
|
+
return { kind: "command", command, returncode: output.returncode, output: output.output };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Autonomous driver: keep taking turns until the run terminates.
|
|
197
|
+
* Returns { exit_status, submission }.
|
|
198
|
+
*/
|
|
199
|
+
async run(task) {
|
|
200
|
+
this.start(task);
|
|
201
|
+
let result;
|
|
202
|
+
while (true) {
|
|
203
|
+
if (this.stepLimit > 0 && this.nSteps >= this.stepLimit) {
|
|
204
|
+
result = this.exit("LimitsExceeded", "");
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
const elapsed = (Date.now() - this.startTime) / 1000;
|
|
208
|
+
if (this.wallTimeLimitSeconds > 0 && elapsed >= this.wallTimeLimitSeconds) {
|
|
209
|
+
result = this.exit("TimeExceeded", "");
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
const turn = await this.runTurn();
|
|
213
|
+
if (turn.kind === "exit") {
|
|
214
|
+
result = { exit_status: turn.exit_status, submission: turn.submission };
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
this.save();
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
exit(status, submission) {
|
|
223
|
+
this.exitStatus = status;
|
|
224
|
+
this.add("exit", submission, { exit_status: status, submission });
|
|
225
|
+
return { kind: "exit", exit_status: status, submission };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
serialize() {
|
|
229
|
+
return {
|
|
230
|
+
trajectory_format: "astra-1",
|
|
231
|
+
id: this.sessionId ?? undefined,
|
|
232
|
+
created: this.sessionCreated,
|
|
233
|
+
title: this.title || undefined,
|
|
234
|
+
info: {
|
|
235
|
+
mode: this.mode,
|
|
236
|
+
task: this.task || undefined,
|
|
237
|
+
exit_status: this.exitStatus ?? "",
|
|
238
|
+
submission: this.messages.findLast((m) => m.role === "exit")?.extra?.submission ?? "",
|
|
239
|
+
n_steps: this.nSteps,
|
|
240
|
+
model: this.model.model,
|
|
241
|
+
n_calls: this.model.nCalls,
|
|
242
|
+
elapsed_seconds: Math.round((Date.now() - this.startTime) / 1000),
|
|
243
|
+
tokens: {
|
|
244
|
+
prompt: this.model.totalPromptTokens,
|
|
245
|
+
completion: this.model.totalCompletionTokens,
|
|
246
|
+
total: this.model.totalPromptTokens + this.model.totalCompletionTokens,
|
|
247
|
+
last_context: this.lastContextTokens,
|
|
248
|
+
},
|
|
249
|
+
cost: {
|
|
250
|
+
usd: this.model.totalCostUsd,
|
|
251
|
+
source: this.model.costSource, // reported | estimated | mixed | null
|
|
252
|
+
reported_usd: this.model.reportedCostUsd,
|
|
253
|
+
estimated_usd: this.model.estimatedCostUsd,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
messages: this.messages,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
save() {
|
|
261
|
+
const doc = this.serialize();
|
|
262
|
+
if (this.sessionId && this._saveSession) {
|
|
263
|
+
this._saveSession(this.sessionId, doc);
|
|
264
|
+
}
|
|
265
|
+
if (!this.outputPath) return;
|
|
266
|
+
fs.mkdirSync(path.dirname(this.outputPath), { recursive: true });
|
|
267
|
+
fs.writeFileSync(this.outputPath, JSON.stringify(doc, null, 2));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Restore messages/counters from a previously serialized session. */
|
|
271
|
+
restore(data) {
|
|
272
|
+
this.messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
273
|
+
this.task = data?.info?.task ?? this.task;
|
|
274
|
+
this.mode = data?.info?.mode === "interactive" ? "interactive" : this.mode;
|
|
275
|
+
this.nSteps = data?.info?.n_steps ?? this.messages.filter((m) => m.role === "assistant").length;
|
|
276
|
+
this.lastContextTokens = data?.info?.tokens?.last_context ?? 0;
|
|
277
|
+
this.exitStatus = data?.info?.exit_status || null;
|
|
278
|
+
if (data?.id) this.sessionId = data.id;
|
|
279
|
+
if (data?.created) this.sessionCreated = data.created;
|
|
280
|
+
if (data?.title) this.title = data.title;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Extract exactly one command from a fenced bash block. */
|
|
285
|
+
export function parseCommand(text) {
|
|
286
|
+
// Accept ```bash, ```sh, or a bare ``` block.
|
|
287
|
+
const re = /```(?:bash|sh)?\s*\n([\s\S]*?)```/g;
|
|
288
|
+
const blocks = [];
|
|
289
|
+
let m;
|
|
290
|
+
while ((m = re.exec(text)) !== null) blocks.push(m[1].trim());
|
|
291
|
+
|
|
292
|
+
if (blocks.length === 0) {
|
|
293
|
+
const err = new Error("No bash code block found. Provide exactly one ```bash ... ``` block.");
|
|
294
|
+
err.code = "NO_COMMAND";
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
if (blocks.length > 1) {
|
|
298
|
+
const err = new Error(`Found ${blocks.length} code blocks; provide exactly one.`);
|
|
299
|
+
err.code = "MULTI_COMMAND";
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
const cmd = blocks[0].trim();
|
|
303
|
+
if (!cmd) {
|
|
304
|
+
const err = new Error("The bash code block was empty.");
|
|
305
|
+
err.code = "EMPTY_COMMAND";
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
return cmd;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Returns the submission string if the sentinel was echoed, else null. */
|
|
312
|
+
export function checkSubmitted(output) {
|
|
313
|
+
if (output.returncode !== 0) return null;
|
|
314
|
+
const lines = output.output.replace(/^\s+/, "").split(/\r?\n/);
|
|
315
|
+
if (lines[0]?.trim() === SUBMIT_SENTINEL) {
|
|
316
|
+
return lines.slice(1).join("\n");
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|