lastlight 0.4.0 → 0.6.1
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/.claude-plugin/marketplace.json +14 -0
- package/README.md +56 -6
- package/agent-context/rules.md +0 -38
- package/agent-context/soul.md +0 -11
- package/deploy/entrypoint.sh +21 -6
- package/deploy/sandbox-entrypoint.sh +1 -1
- package/dist/cli.js +24 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +13 -2
- package/dist/config.js +29 -9
- package/dist/config.js.map +1 -1
- package/dist/connectors/messaging/base.js +22 -9
- package/dist/connectors/messaging/base.js.map +1 -1
- package/dist/connectors/slack/connector.d.ts +62 -9
- package/dist/connectors/slack/connector.js +216 -63
- package/dist/connectors/slack/connector.js.map +1 -1
- package/dist/connectors/slack/connector.test.d.ts +1 -0
- package/dist/connectors/slack/connector.test.js +135 -0
- package/dist/connectors/slack/connector.test.js.map +1 -0
- package/dist/connectors/types.d.ts +7 -0
- package/dist/engine/agent-executor.d.ts +29 -144
- package/dist/engine/agent-executor.js +280 -962
- package/dist/engine/agent-executor.js.map +1 -1
- package/dist/engine/agent-executor.seam.test.d.ts +1 -0
- package/dist/engine/agent-executor.seam.test.js +57 -0
- package/dist/engine/agent-executor.seam.test.js.map +1 -0
- package/dist/engine/dispatcher.d.ts +13 -0
- package/dist/engine/dispatcher.js +26 -7
- package/dist/engine/dispatcher.js.map +1 -1
- package/dist/engine/dispatcher.test.js.map +1 -1
- package/dist/engine/executors/backends.d.ts +41 -0
- package/dist/engine/executors/backends.js +541 -0
- package/dist/engine/executors/backends.js.map +1 -0
- package/dist/engine/executors/shared.d.ts +189 -0
- package/dist/engine/executors/shared.js +612 -0
- package/dist/engine/executors/shared.js.map +1 -0
- package/dist/engine/message-batcher.d.ts +32 -0
- package/dist/engine/message-batcher.js +109 -0
- package/dist/engine/message-batcher.js.map +1 -0
- package/dist/engine/message-batcher.test.d.ts +1 -0
- package/dist/engine/message-batcher.test.js +109 -0
- package/dist/engine/message-batcher.test.js.map +1 -0
- package/dist/engine/profiles.d.ts +9 -0
- package/dist/engine/profiles.js.map +1 -1
- package/dist/evals-api.d.ts +24 -0
- package/dist/evals-api.js +22 -0
- package/dist/evals-api.js.map +1 -0
- package/dist/index.js +41 -4
- package/dist/index.js.map +1 -1
- package/dist/sandbox/command-exec.integration.test.d.ts +1 -0
- package/dist/sandbox/command-exec.integration.test.js +183 -0
- package/dist/sandbox/command-exec.integration.test.js.map +1 -0
- package/dist/sandbox/docker.d.ts +32 -0
- package/dist/sandbox/docker.js +109 -4
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/smol.d.ts +162 -0
- package/dist/sandbox/smol.integration.test.d.ts +1 -0
- package/dist/sandbox/smol.integration.test.js +130 -0
- package/dist/sandbox/smol.integration.test.js.map +1 -0
- package/dist/sandbox/smol.js +485 -0
- package/dist/sandbox/smol.js.map +1 -0
- package/dist/skills-install.d.ts +12 -0
- package/dist/skills-install.js +179 -0
- package/dist/skills-install.js.map +1 -0
- package/dist/workflows/loader.test.js +83 -0
- package/dist/workflows/loader.test.js.map +1 -1
- package/dist/workflows/phase-executor.d.ts +28 -3
- package/dist/workflows/phase-executor.js +170 -45
- package/dist/workflows/phase-executor.js.map +1 -1
- package/dist/workflows/phase-executor.test.js +64 -1
- package/dist/workflows/phase-executor.test.js.map +1 -1
- package/dist/workflows/runner.test.js +32 -27
- package/dist/workflows/runner.test.js.map +1 -1
- package/dist/workflows/schema.d.ts +20 -0
- package/dist/workflows/schema.js +72 -2
- package/dist/workflows/schema.js.map +1 -1
- package/package.json +15 -3
- package/plugins/lastlight/.claude-plugin/plugin.json +12 -0
- package/plugins/lastlight/README.md +44 -0
- package/plugins/lastlight/skills/lastlight-client/SKILL.md +82 -0
- package/plugins/lastlight/skills/lastlight-evals/SKILL.md +97 -0
- package/plugins/lastlight/skills/lastlight-evals/references/instance-schema.md +84 -0
- package/plugins/lastlight/skills/lastlight-evals/references/models-json.md +42 -0
- package/plugins/lastlight/skills/lastlight-overlay/SKILL.md +72 -0
- package/plugins/lastlight/skills/lastlight-overlay/references/forking.md +55 -0
- package/plugins/lastlight/skills/lastlight-overlay/references/overlay-layout.md +52 -0
- package/plugins/lastlight/skills/lastlight-server/SKILL.md +124 -0
- package/plugins/lastlight/skills/lastlight-server/references/env-schema.md +103 -0
- package/plugins/lastlight/skills/lastlight-server/references/operations.md +60 -0
- package/sandbox.Dockerfile +7 -0
- package/skills/browser-qa/scripts/agent-browser.mjs +15 -0
- package/skills/demo/SKILL.md +45 -19
- package/skills/demo/scripts/compose-demo.sh +77 -39
- package/workflows/examples/bash-smoke.yaml +41 -0
- package/workflows/prompts/demo.md +27 -5
- package/workflows/qa-test.yaml +1 -1
- package/workflows/verify.yaml +1 -1
|
@@ -1,181 +1,29 @@
|
|
|
1
|
-
import { resolve,
|
|
2
|
-
import {
|
|
1
|
+
import { resolve, join } from "path";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { spawnSync } from "child_process";
|
|
3
4
|
import { randomUUID } from "crypto";
|
|
4
|
-
import { createTaskSandbox, setupTaskWorktree, SANDBOX_IMAGE_QA, } from "../sandbox/index.js";
|
|
5
|
+
import { createTaskSandbox, setupTaskWorktree, prePopulateWorkspace, SANDBOX_IMAGE_QA, } from "../sandbox/index.js";
|
|
6
|
+
import { SmolSandbox, smolAvailable, SMOL_WORKSPACE_DIR } from "../sandbox/smol.js";
|
|
5
7
|
import { refreshGitAuth } from "./git-auth.js";
|
|
6
|
-
import {
|
|
7
|
-
import { AgenticShim
|
|
8
|
+
import { GITHUB_PERMISSION_PROFILES, loadAgentContext, } from "./profiles.js";
|
|
9
|
+
import { AgenticShim } from "./event-shim.js";
|
|
8
10
|
import { projectSlugForCwd } from "../session-log.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
// `.agents/skills` (pi's auto-discovery path): we map each phase's bundle
|
|
17
|
-
// explicitly via --skill / skillPaths so two phases sharing a workspace —
|
|
18
|
-
// sequential today, parallel via worktrees tomorrow — can never clobber each
|
|
19
|
-
// other's catalogue. The agent keeps cwd = the repo (no `cd` preamble on
|
|
20
|
-
// every command); the bundle is staged at the workspace ROOT — a sibling of
|
|
21
|
-
// the repo, never in its git tree — and reached by an absolute path. On
|
|
22
|
-
// docker/none that root is genuinely outside the repo. gondolin mounts only
|
|
23
|
-
// cwd, so there the bundle is staged under the repo and added to the
|
|
24
|
-
// checkout's local `.git/info/exclude` (never committed; see `excludeFromGit`).
|
|
25
|
-
const SKILL_BUNDLE_ROOT = ".lastlight-skills";
|
|
26
|
-
const THINKING_LEVELS = new Set([
|
|
27
|
-
"off", "minimal", "low", "medium", "high", "xhigh",
|
|
28
|
-
]);
|
|
11
|
+
import { DEFAULT_ALLOWLIST, mergeAllowlist } from "../sandbox/egress-allowlist.js";
|
|
12
|
+
import { getDockerSandboxOtelEnv, getOtelEnvForSandbox, safeSpanAttributes, withSpan } from "../telemetry/index.js";
|
|
13
|
+
// Per-backend executors + their shared building blocks live under ./executors/.
|
|
14
|
+
import { executeInProcess, executeDocker, executeSmol } from "./executors/backends.js";
|
|
15
|
+
import { DEFAULT_MODEL, DOCKER_WORKSPACE_DIR, resolveSessionsDir } from "./executors/shared.js";
|
|
16
|
+
// Re-exported for back-compat with existing importers (tests, dashboards).
|
|
17
|
+
export { RunResultAccumulator, stageSkillBundle, excludeFromGit, detectAccountError } from "./executors/shared.js";
|
|
29
18
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* Keyed by phase so concurrent phases in one workspace never touch each
|
|
38
|
-
* other's bundle: only the phase's own `<phaseKey>` subtree is cleared, so a
|
|
39
|
-
* clean slate per phase doesn't disturb a sibling phase mid-run.
|
|
40
|
-
*
|
|
41
|
-
* `mode` controls how each skill lands:
|
|
42
|
-
* - "symlink": one symlink per skill → host source. gondolin/none, where pi
|
|
43
|
-
* reads skill files host-side / through the cwd mount. Zero-copy.
|
|
44
|
-
* - "copy": recursive copy. docker, where the agent's tools run inside the
|
|
45
|
-
* container and host symlink targets wouldn't resolve; the copy lands
|
|
46
|
-
* under the bind-mounted workspace.
|
|
47
|
-
*
|
|
48
|
-
* Returns `undefined` when the phase declares no skills (after clearing its
|
|
49
|
-
* bundle), so a phase with no `skills:` gets no catalogue at all.
|
|
19
|
+
* Shared run preparation for {@link executeAgent} and {@link executeCommand}:
|
|
20
|
+
* resolve the taskId / state dir / backend, mint the scoped GitHub token,
|
|
21
|
+
* assemble the sandbox env (git token, provider keys, OTEL), and compute the
|
|
22
|
+
* pre-populate descriptor. Both the agent and the deterministic command paths
|
|
23
|
+
* run in the same sandbox/workspace with the same git access, so they share
|
|
24
|
+
* this setup verbatim.
|
|
50
25
|
*/
|
|
51
|
-
|
|
52
|
-
const bundleDir = join(workspaceRoot, SKILL_BUNDLE_ROOT, phaseKey);
|
|
53
|
-
if (existsSync(bundleDir)) {
|
|
54
|
-
rmSync(bundleDir, { recursive: true, force: true });
|
|
55
|
-
}
|
|
56
|
-
if (!skillPaths?.length)
|
|
57
|
-
return undefined;
|
|
58
|
-
mkdirSync(bundleDir, { recursive: true });
|
|
59
|
-
const staged = [];
|
|
60
|
-
for (const hostPath of skillPaths) {
|
|
61
|
-
const dest = join(bundleDir, basename(hostPath));
|
|
62
|
-
if (mode === "symlink") {
|
|
63
|
-
symlinkSync(hostPath, dest, "dir");
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
cpSync(hostPath, dest, { recursive: true, dereference: true });
|
|
67
|
-
}
|
|
68
|
-
staged.push(dest);
|
|
69
|
-
}
|
|
70
|
-
return staged;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Sanitized per-phase key for the skill bundle directory. Phase name first
|
|
74
|
-
* (unique even for loop iterations like `reviewer_fix_1`), then workflow name,
|
|
75
|
-
* then a constant fallback — so the bundle is always isolated per phase.
|
|
76
|
-
*/
|
|
77
|
-
function skillBundleKey(config) {
|
|
78
|
-
const raw = config.telemetry?.phaseName || config.telemetry?.workflowName || "phase";
|
|
79
|
-
return raw.replace(/[^A-Za-z0-9_-]/g, "_") || "phase";
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Add `entry` to a checkout's local `.git/info/exclude` (idempotent) so the
|
|
83
|
-
* agent's own `git add`/`commit` can never pick it up. This file lives inside
|
|
84
|
-
* `.git/` — it is never tracked, committed, or pushed, and it leaves the repo's
|
|
85
|
-
* real `.gitignore` untouched; the exclusion applies only to this ephemeral
|
|
86
|
-
* sandbox checkout. Used for the gondolin backend, where the skill bundle must
|
|
87
|
-
* be staged under cwd (the only mounted dir) rather than as an out-of-repo
|
|
88
|
-
* sibling. No-op when `repoDir` isn't a git checkout (e.g. the workspace root).
|
|
89
|
-
*/
|
|
90
|
-
export function excludeFromGit(repoDir, entry) {
|
|
91
|
-
const gitDir = join(repoDir, ".git");
|
|
92
|
-
if (!existsSync(gitDir))
|
|
93
|
-
return; // not a checkout — nothing to exclude
|
|
94
|
-
const infoDir = join(gitDir, "info");
|
|
95
|
-
const excludeFile = join(infoDir, "exclude");
|
|
96
|
-
const line = `/${entry}/`;
|
|
97
|
-
let current = "";
|
|
98
|
-
try {
|
|
99
|
-
current = readFileSync(excludeFile, "utf8");
|
|
100
|
-
}
|
|
101
|
-
catch { /* may not exist yet */ }
|
|
102
|
-
if (current.split(/\r?\n/).includes(line))
|
|
103
|
-
return;
|
|
104
|
-
mkdirSync(infoDir, { recursive: true });
|
|
105
|
-
appendFileSync(excludeFile, `${current.length && !current.endsWith("\n") ? "\n" : ""}${line}\n`);
|
|
106
|
-
}
|
|
107
|
-
// ── Server-mode build assets ────────────────────────────────────────
|
|
108
|
-
//
|
|
109
|
-
// In "server" mode the per-phase handoff docs live in the Last Light store
|
|
110
|
-
// rather than being committed into the target repo. The seam is symmetric to
|
|
111
|
-
// the skill bundle but bidirectional: stage the run's stored docs into the
|
|
112
|
-
// repo's `.lastlight/<issueKey>/` before the agent runs (so a later phase /
|
|
113
|
-
// resumed run sees prior context), and harvest whatever the phase wrote back
|
|
114
|
-
// to the store afterwards. The directory is the SAME relative path as repo
|
|
115
|
-
// mode (`{{issueDir}}`), so prompts are unchanged except for gating their
|
|
116
|
-
// `git add .lastlight/ && commit` off — the dir is git-excluded here too as a
|
|
117
|
-
// backstop against the agent's `git add -A` feature commit sweeping it in.
|
|
118
|
-
const ARTIFACT_DIR_ROOT = ".lastlight";
|
|
119
|
-
/**
|
|
120
|
-
* Resolve the server-mode artifact context for a run, or undefined when not in
|
|
121
|
-
* server mode (the default — the whole seam is then skipped and behaviour is
|
|
122
|
-
* byte-for-byte repo mode). `hostRepoDir` is the host-visible repo checkout
|
|
123
|
-
* (for docker that's the bind-mounted workspace path, not the in-container one).
|
|
124
|
-
*/
|
|
125
|
-
function serverArtifacts(config, hostRepoDir) {
|
|
126
|
-
if (config.buildAssets !== "server" || !config.buildAssetsDir || !config.buildAssetsKey) {
|
|
127
|
-
return undefined;
|
|
128
|
-
}
|
|
129
|
-
const store = new BuildAssetStore(config.buildAssetsDir);
|
|
130
|
-
const ref = config.buildAssetsKey;
|
|
131
|
-
return { store, ref, dir: join(hostRepoDir, ARTIFACT_DIR_ROOT, ref.issueKey), repoDir: hostRepoDir };
|
|
132
|
-
}
|
|
133
|
-
/** Stage stored docs into the workspace + exclude the dir from git (server mode). */
|
|
134
|
-
function stageArtifactsIn(art) {
|
|
135
|
-
if (!art)
|
|
136
|
-
return;
|
|
137
|
-
try {
|
|
138
|
-
// Stage the run's stored docs into `<repoDir>/.lastlight/<issueKey>/`. This
|
|
139
|
-
// is safe for every workflow shape here: pre-cloned workflows (build, pr-*)
|
|
140
|
-
// already have the checkout at `repoDir`, and non-pre-cloned ones (explore)
|
|
141
|
-
// clone the repo into a *subdir* — so `repoDir` is the workspace root and a
|
|
142
|
-
// `.lastlight/` there never collides with the agent's clone. (A future
|
|
143
|
-
// workflow that `git clone … .` into cwd would be the one exception.)
|
|
144
|
-
art.store.stageInto(art.ref, art.dir);
|
|
145
|
-
// Backstop the prompt-level commit gate: when this is a real checkout, keep
|
|
146
|
-
// the docs out of the agent's `git add -A` feature commit. No-op at the
|
|
147
|
-
// workspace root (no `.git`), where the docs sit outside the repo subtree
|
|
148
|
-
// and are never in the repo's git tree anyway.
|
|
149
|
-
excludeFromGit(art.repoDir, ARTIFACT_DIR_ROOT);
|
|
150
|
-
}
|
|
151
|
-
catch (err) {
|
|
152
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
153
|
-
console.warn(`[executor] Could not stage build assets: ${msg}`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
/** Persist docs the phase wrote back to the store (server mode). */
|
|
157
|
-
function harvestArtifactsOut(art) {
|
|
158
|
-
if (!art)
|
|
159
|
-
return;
|
|
160
|
-
try {
|
|
161
|
-
art.store.harvestFrom(art.ref, art.dir);
|
|
162
|
-
}
|
|
163
|
-
catch (err) {
|
|
164
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
165
|
-
console.warn(`[executor] Could not harvest build assets: ${msg}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* Execute one workflow-phase agent task via agentic-pi.
|
|
170
|
-
*
|
|
171
|
-
* Sandbox backend (decided per call):
|
|
172
|
-
* 1. gondolin — agentic-pi's QEMU micro-VM. cwd is the host worktree,
|
|
173
|
-
* mounted at /workspace inside the VM.
|
|
174
|
-
* 2. docker — the legacy container path (`src/sandbox/docker.ts`). The
|
|
175
|
-
* entrypoint inside the container execs `agentic-pi run --sandbox none`.
|
|
176
|
-
* 3. none — agentic-pi in-process with cwd = the host worktree.
|
|
177
|
-
*/
|
|
178
|
-
export async function executeAgent(prompt, config, opts) {
|
|
26
|
+
async function prepareRun(config, opts) {
|
|
179
27
|
const taskId = opts?.taskId || `task-${randomUUID().slice(0, 8)}`;
|
|
180
28
|
const stateDir = config.stateDir || resolve("data");
|
|
181
29
|
const backend = config.sandbox ?? "gondolin";
|
|
@@ -274,6 +122,11 @@ export async function executeAgent(prompt, config, opts) {
|
|
|
274
122
|
shallow: access.shallow,
|
|
275
123
|
}
|
|
276
124
|
: undefined;
|
|
125
|
+
return { taskId, stateDir, backend, ghEnv, mintedToken, prePopulate };
|
|
126
|
+
}
|
|
127
|
+
export async function executeAgent(prompt, config, opts) {
|
|
128
|
+
const { taskId, stateDir, backend, ghEnv, prePopulate } = await prepareRun(config, opts);
|
|
129
|
+
const access = opts?.githubAccess;
|
|
277
130
|
const spanAttrs = safeSpanAttributes({
|
|
278
131
|
"agent.runtime": "agentic-pi",
|
|
279
132
|
"sandbox.backend": backend,
|
|
@@ -297,6 +150,16 @@ export async function executeAgent(prompt, config, opts) {
|
|
|
297
150
|
onSessionId: opts?.onSessionId,
|
|
298
151
|
}));
|
|
299
152
|
}
|
|
153
|
+
if (backend === "smol") {
|
|
154
|
+
return withSpan("lastlight.agent.execute", spanAttrs, () => executeSmol(prompt, config, {
|
|
155
|
+
taskId,
|
|
156
|
+
stateDir,
|
|
157
|
+
env: ghEnv,
|
|
158
|
+
prePopulate,
|
|
159
|
+
access,
|
|
160
|
+
onSessionId: opts?.onSessionId,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
300
163
|
const workDir = setupTaskWorktree({
|
|
301
164
|
taskId,
|
|
302
165
|
stateDir,
|
|
@@ -324,817 +187,272 @@ export async function executeAgent(prompt, config, opts) {
|
|
|
324
187
|
onSessionId: opts?.onSessionId,
|
|
325
188
|
}));
|
|
326
189
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
console.warn(`[executor] Could not stage skills: ${msg}`);
|
|
358
|
-
}
|
|
359
|
-
// Server-mode build assets: agentCwd is the host-visible repo checkout for
|
|
360
|
-
// the in-process backends (gondolin's cwd mount / none's host FS), so stage
|
|
361
|
-
// and harvest operate on it directly.
|
|
362
|
-
const artifacts = serverArtifacts(config, agentCwd);
|
|
363
|
-
stageArtifactsIn(artifacts);
|
|
190
|
+
const SCRIPT_EXT = { js: "mjs", ts: "mts", python: "py" };
|
|
191
|
+
/**
|
|
192
|
+
* Where a `type: script` source file is staged. A workspace-root sibling of
|
|
193
|
+
* {@link SKILL_BUNDLE_ROOT}, keyed per phase (`<root>/<phase>/script.<ext>`) —
|
|
194
|
+
* same convention as the skill bundle, so it sits beside the skills and is
|
|
195
|
+
* never written inside any checked-out repo's git tree.
|
|
196
|
+
*/
|
|
197
|
+
const SCRIPT_BUNDLE_ROOT = ".lastlight-scripts";
|
|
198
|
+
/** Build the shell invocation + on-disk filename for a script spec. */
|
|
199
|
+
function scriptInvocation(spec) {
|
|
200
|
+
const fileName = `script.${SCRIPT_EXT[spec.runtime]}`;
|
|
201
|
+
const run = (path) => {
|
|
202
|
+
switch (spec.runtime) {
|
|
203
|
+
case "js":
|
|
204
|
+
return `node ${path}`;
|
|
205
|
+
case "ts":
|
|
206
|
+
return `node --experimental-strip-types ${path}`;
|
|
207
|
+
case "python":
|
|
208
|
+
return `uv run ${path}`;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
return { fileName, run };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Mirror a finished command into a session jsonl via the shim. Synthesizes a
|
|
215
|
+
* minimal agentic-pi event stream: session → assistant(tool_use bash) →
|
|
216
|
+
* user(tool_result) → assistant(text summary) → result. Returns the session id
|
|
217
|
+
* the shim wrote under (so the executions row can link to it).
|
|
218
|
+
*/
|
|
219
|
+
async function writeCommandSession(opts) {
|
|
364
220
|
const shim = new AgenticShim({
|
|
365
|
-
homeDir: sessionsDir,
|
|
366
|
-
projectSlug:
|
|
367
|
-
model,
|
|
368
|
-
initialPrompt:
|
|
221
|
+
homeDir: opts.sessionsDir,
|
|
222
|
+
projectSlug: opts.projectSlug,
|
|
223
|
+
model: opts.model,
|
|
224
|
+
initialPrompt: opts.displayPrompt,
|
|
369
225
|
});
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// (so the host's HOME — e.g. /Users/clifton — leaks in). We force
|
|
381
|
-
// HOME=/root so `git config --global` and `gh auth status` write
|
|
382
|
-
// to a real path inside the VM.
|
|
383
|
-
// - none: agent runs on the host; do NOT override HOME (would mess
|
|
384
|
-
// with the harness user's real config).
|
|
385
|
-
// - docker (handled separately): the container's `agent` user has
|
|
386
|
-
// its own HOME=/home/agent baked in by the image; no override.
|
|
387
|
-
const baseSandboxEnv = {
|
|
388
|
-
GIT_AUTHOR_NAME: "last-light[bot]",
|
|
389
|
-
GIT_AUTHOR_EMAIL: "last-light[bot]@users.noreply.github.com",
|
|
390
|
-
GIT_COMMITTER_NAME: "last-light[bot]",
|
|
391
|
-
GIT_COMMITTER_EMAIL: "last-light[bot]@users.noreply.github.com",
|
|
392
|
-
// /workspace is a bind mount from the host (host UID) into a VM running
|
|
393
|
-
// as a different UID; git refuses to operate without an explicit
|
|
394
|
-
// safe-directory. Setting it via GIT_CONFIG_* avoids needing HOME and
|
|
395
|
-
// a writeable ~/.gitconfig.
|
|
396
|
-
GIT_CONFIG_COUNT: "1",
|
|
397
|
-
GIT_CONFIG_KEY_0: "safe.directory",
|
|
398
|
-
GIT_CONFIG_VALUE_0: "*",
|
|
399
|
-
};
|
|
400
|
-
const otelSandboxEnv = config.otel?.enabled && config.otel.forwardToSandbox ? getOtelEnvForSandbox() : {};
|
|
401
|
-
const sandboxEnv = ctx.backend === "gondolin"
|
|
402
|
-
? { ...otelSandboxEnv, ...baseSandboxEnv, HOME: "/root", USER: "root", LOGNAME: "root" }
|
|
403
|
-
: { ...otelSandboxEnv, ...baseSandboxEnv };
|
|
404
|
-
let notifiedSessionId = false;
|
|
405
|
-
let result;
|
|
406
|
-
const acc = new RunResultAccumulator();
|
|
407
|
-
try {
|
|
408
|
-
// HTTP egress allowlist. lastlight owns the policy (rather than relying
|
|
409
|
-
// on agentic-pi's bundled default) so a single source — `egress-allowlist.ts`
|
|
410
|
-
// — covers both backends. `unrestrictedEgress` opts a phase out via the
|
|
411
|
-
// `"*"` sentinel; gondolin (post the upstream allow-all patch) treats it
|
|
412
|
-
// as "allow every host".
|
|
413
|
-
const extraHosts = config.otel?.enabled && config.otel.forwardToSandbox ? config.otel.collectorHosts : [];
|
|
414
|
-
const allowedHttpHosts = config.unrestrictedEgress
|
|
415
|
-
? [ALLOW_ALL_SENTINEL]
|
|
416
|
-
: mergeAllowlist(DEFAULT_ALLOWLIST, extraHosts);
|
|
417
|
-
// Loaded lazily: agentic-pi transitively imports pi-coding-agent, whose
|
|
418
|
-
// bundled undici writes a v8 Agent onto `Symbol.for('undici.globalDispatcher.1')`
|
|
419
|
-
// the moment its `lib/global.js` evaluates. Node's built-in fetch reads
|
|
420
|
-
// from the same symbol, so eager-loading here would poison every fetch
|
|
421
|
-
// in the harness — breaking arctic's OAuth code exchange (strict
|
|
422
|
-
// content-length validation). Dynamic import keeps the harness on
|
|
423
|
-
// Node's clean dispatcher unless an in-process sandbox actually runs.
|
|
424
|
-
const { run: agenticRun } = await import("agentic-pi");
|
|
425
|
-
result = await agenticRun({
|
|
426
|
-
model,
|
|
427
|
-
prompt,
|
|
428
|
-
thinking,
|
|
429
|
-
profile,
|
|
430
|
-
sandbox: ctx.backend === "gondolin" ? "gondolin" : "none",
|
|
431
|
-
sandboxEnv,
|
|
432
|
-
cwd: agentCwd,
|
|
433
|
-
noSession: true,
|
|
434
|
-
// Explicit per-phase skill bundle (staged above). pi loads these
|
|
435
|
-
// additively; nothing is written into the repo for the agent to commit.
|
|
436
|
-
skillPaths: stagedSkillDirs,
|
|
437
|
-
allowedHttpHosts,
|
|
438
|
-
// Explicit boolean — without it, agentic-pi auto-enables web search
|
|
439
|
-
// when any provider key is in process.env. We forwarded those keys
|
|
440
|
-
// above only for opted-in workflows, but `process.env` on the harness
|
|
441
|
-
// host carries them regardless, so we must opt-out explicitly.
|
|
442
|
-
webSearch: config.webSearch === true,
|
|
443
|
-
webSearchProvider: config.webSearchProvider,
|
|
444
|
-
onEvent: (record) => {
|
|
445
|
-
acc.feed(record);
|
|
446
|
-
shim.feed(record);
|
|
447
|
-
recordPiEvent(record, {
|
|
448
|
-
includeContent: config.otel?.includeContent === true,
|
|
449
|
-
surface: "agent",
|
|
450
|
-
workflowName: config.telemetry?.workflowName,
|
|
451
|
-
phaseName: config.telemetry?.phaseName,
|
|
452
|
-
model,
|
|
453
|
-
});
|
|
454
|
-
if (!notifiedSessionId && ctx.onSessionId && record.type === "session" && typeof record.id === "string") {
|
|
455
|
-
notifiedSessionId = true;
|
|
456
|
-
ctx.onSessionId(record.id);
|
|
457
|
-
}
|
|
458
|
-
},
|
|
459
|
-
onWarn: (msg) => console.warn(`[agentic] ${msg}`),
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
catch (err) {
|
|
463
|
-
restore();
|
|
464
|
-
// Harvest even on failure so a partial plan/summary still reaches the store.
|
|
465
|
-
harvestArtifactsOut(artifacts);
|
|
466
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
467
|
-
const durationMs = Date.now() - startTime;
|
|
468
|
-
recordError("agent", err, { "sandbox.backend": ctx.backend, model, success: false, stop_reason: "error_executor", "workflow.name": config.telemetry?.workflowName, "phase.name": config.telemetry?.phaseName });
|
|
469
|
-
recordExecutionMetrics("agent", { "sandbox.backend": ctx.backend, model, success: false, stop_reason: "error_executor", durationMs });
|
|
470
|
-
const fallbackId = `exec-${basename(ctx.taskId)}`;
|
|
471
|
-
const synthesizedId = await shim
|
|
472
|
-
.finalizeWithFallback(emptyResult("error_executor", durationMs), fallbackId, msg)
|
|
473
|
-
.catch(() => null);
|
|
474
|
-
return {
|
|
475
|
-
success: false,
|
|
476
|
-
output: "",
|
|
477
|
-
turns: 0,
|
|
478
|
-
error: msg,
|
|
479
|
-
durationMs,
|
|
480
|
-
sessionId: synthesizedId ?? undefined,
|
|
481
|
-
stopReason: "error_executor",
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
restore();
|
|
485
|
-
harvestArtifactsOut(artifacts);
|
|
486
|
-
// agentic-pi's in-process `stats` is the same compaction-blind
|
|
487
|
-
// `usage_snapshot`. Prefer our per-message accumulation when it carries
|
|
488
|
-
// token data (see RunResultAccumulator.bestStats).
|
|
489
|
-
const better = acc.bestStats();
|
|
490
|
-
if (better && (better.tokens?.total ?? 0) > 0)
|
|
491
|
-
result.stats = better;
|
|
492
|
-
const finalResult = await finalizeFromRunResult(result, prompt, shim, startTime, acc.extensions(), acc.skills(), acc.toolError(), acc.endedOnToolCall());
|
|
493
|
-
recordExecutionMetrics("agent", {
|
|
494
|
-
"sandbox.backend": ctx.backend,
|
|
495
|
-
model,
|
|
496
|
-
success: finalResult.success,
|
|
497
|
-
stop_reason: finalResult.stopReason,
|
|
498
|
-
durationMs: finalResult.durationMs,
|
|
499
|
-
costUsd: finalResult.costUsd,
|
|
500
|
-
inputTokens: finalResult.inputTokens,
|
|
501
|
-
outputTokens: finalResult.outputTokens,
|
|
502
|
-
"workflow.name": config.telemetry?.workflowName,
|
|
503
|
-
"phase.name": config.telemetry?.phaseName,
|
|
226
|
+
const sessionId = randomUUID();
|
|
227
|
+
const ts = Date.now();
|
|
228
|
+
const toolCallId = `cmd_${randomUUID().slice(0, 8)}`;
|
|
229
|
+
const feed = (record) => shim.feed(record);
|
|
230
|
+
feed({ type: "session", id: sessionId, timestamp: ts });
|
|
231
|
+
feed({
|
|
232
|
+
type: "message_end",
|
|
233
|
+
sessionId,
|
|
234
|
+
timestamp: ts,
|
|
235
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: toolCallId, name: opts.toolName, arguments: opts.toolInput }] },
|
|
504
236
|
});
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// The IPs here match the static assignments in docker-compose.yml
|
|
514
|
-
// (see src/sandbox/egress-firewall-config.ts for the constants).
|
|
515
|
-
const dnsIp = config.unrestrictedEgress
|
|
516
|
-
? (process.env.LASTLIGHT_DNS_OPEN || "172.30.0.11")
|
|
517
|
-
: (process.env.LASTLIGHT_DNS_STRICT || "172.30.0.10");
|
|
518
|
-
// A phase can opt into the browser-QA image (Playwright + Chromium baked in)
|
|
519
|
-
// with `sandbox_image: qa`. The runner only schedules such a phase when the
|
|
520
|
-
// image is actually present (see `qaImageAvailable`), so by here it's safe to
|
|
521
|
-
// request; pass undefined otherwise to use the lean default image.
|
|
522
|
-
const imageName = config.sandboxImage === "qa" ? SANDBOX_IMAGE_QA : undefined;
|
|
523
|
-
const sbx = await createTaskSandbox({
|
|
524
|
-
taskId: ctx.taskId,
|
|
525
|
-
stateDir: ctx.stateDir,
|
|
526
|
-
sandboxDir: config.sandboxDir,
|
|
527
|
-
env: ctx.env,
|
|
528
|
-
prePopulate: ctx.prePopulate,
|
|
529
|
-
dnsIp,
|
|
530
|
-
imageName,
|
|
237
|
+
const combined = opts.stderr ? `${opts.stdout}${opts.stdout && !opts.stdout.endsWith("\n") ? "\n" : ""}${opts.stderr}` : opts.stdout;
|
|
238
|
+
feed({
|
|
239
|
+
type: "tool_execution_end",
|
|
240
|
+
sessionId,
|
|
241
|
+
timestamp: ts,
|
|
242
|
+
toolCallId,
|
|
243
|
+
result: combined || `(no output, exit ${opts.exitCode})`,
|
|
244
|
+
isError: opts.exitCode !== 0,
|
|
531
245
|
});
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (md)
|
|
539
|
-
writeFileSync(join(sbx.workDir, "AGENTS.md"), md);
|
|
540
|
-
}
|
|
541
|
-
catch (err) {
|
|
542
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
543
|
-
console.warn(`[executor] Could not write docker AGENTS.md: ${msg}`);
|
|
544
|
-
}
|
|
545
|
-
const startTime = Date.now();
|
|
546
|
-
console.log(` [executor] Running in docker sandbox (task: ${ctx.taskId})`);
|
|
547
|
-
const model = config.model || DEFAULT_MODEL;
|
|
548
|
-
const thinking = coerceThinking(config.variant);
|
|
549
|
-
const profile = ctx.access ? AGENTIC_PROFILE_FOR[ctx.access.profile] : undefined;
|
|
550
|
-
const sessionsDir = resolveSessionsDir(config);
|
|
551
|
-
// When the harness pre-cloned the repo, cwd is the checkout
|
|
552
|
-
// (`<workspace>/<repo>/`) so the agent runs inside the repo with no `cd`
|
|
553
|
-
// preamble; otherwise it's the workspace root.
|
|
554
|
-
const agentCwd = ctx.prePopulate
|
|
555
|
-
? `${DOCKER_WORKSPACE_DIR}/${ctx.prePopulate.repo}`
|
|
556
|
-
: DOCKER_WORKSPACE_DIR;
|
|
557
|
-
// Stage this phase's skills into its own bundle at the workspace ROOT — a
|
|
558
|
-
// sibling of any repo subdir, never inside its git tree. docker bind-mounts
|
|
559
|
-
// the WHOLE workspace, so the agent reaches the bundle by an absolute
|
|
560
|
-
// `--skill` path even though cwd is the repo. Copy (not symlink) because the
|
|
561
|
-
// agent's tools run inside the container and host symlink targets don't
|
|
562
|
-
// resolve there. Map the host dests to their in-container paths for `--skill`.
|
|
563
|
-
let skillDirsInContainer = [];
|
|
564
|
-
try {
|
|
565
|
-
const staged = stageSkillBundle(sbx.workDir, skillBundleKey(config), config.skillPaths, "copy");
|
|
566
|
-
if (staged) {
|
|
567
|
-
skillDirsInContainer = staged.map((d) => `${DOCKER_WORKSPACE_DIR}/${relative(sbx.workDir, d)}`);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
catch (err) {
|
|
571
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
572
|
-
console.warn(`[executor] Could not stage skills in docker workspace: ${msg}`);
|
|
573
|
-
}
|
|
574
|
-
// Server-mode build assets. The host-visible repo checkout is the
|
|
575
|
-
// bind-mounted workspace path (`sbx.workDir[/<repo>]`), NOT the in-container
|
|
576
|
-
// `agentCwd`. Stage now; harvest before each `sbx.cleanup()` below (cleanup
|
|
577
|
-
// removes the workspace, taking the docs with it).
|
|
578
|
-
const hostRepoDir = ctx.prePopulate ? join(sbx.workDir, ctx.prePopulate.repo) : sbx.workDir;
|
|
579
|
-
const artifacts = serverArtifacts(config, hostRepoDir);
|
|
580
|
-
stageArtifactsIn(artifacts);
|
|
581
|
-
// The dashboard reads from <sessionsDir>/projects/<slug>/. Use the same
|
|
582
|
-
// resolved cwd for the slug so live tails land in the right project dir.
|
|
583
|
-
const shim = new AgenticShim({
|
|
584
|
-
homeDir: sessionsDir,
|
|
585
|
-
projectSlug: projectSlugForCwd(agentCwd),
|
|
586
|
-
model,
|
|
587
|
-
initialPrompt: prompt,
|
|
246
|
+
const summary = opts.exitCode === 0 ? "Command succeeded (exit 0)." : `Command failed (exit ${opts.exitCode}).`;
|
|
247
|
+
feed({
|
|
248
|
+
type: "message_end",
|
|
249
|
+
sessionId,
|
|
250
|
+
timestamp: ts,
|
|
251
|
+
message: { role: "assistant", content: [{ type: "text", text: summary }] },
|
|
588
252
|
});
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
// tunnelled the same way the agent's own telemetry is.
|
|
600
|
-
...(config.otel?.enabled && config.otel.forwardToSandbox ? getDockerSandboxOtelEnv() : {}),
|
|
601
|
-
GIT_AUTHOR_NAME: "last-light[bot]",
|
|
602
|
-
GIT_AUTHOR_EMAIL: "last-light[bot]@users.noreply.github.com",
|
|
603
|
-
GIT_COMMITTER_NAME: "last-light[bot]",
|
|
604
|
-
GIT_COMMITTER_EMAIL: "last-light[bot]@users.noreply.github.com",
|
|
605
|
-
GIT_CONFIG_COUNT: "1",
|
|
606
|
-
GIT_CONFIG_KEY_0: "safe.directory",
|
|
607
|
-
GIT_CONFIG_VALUE_0: "*",
|
|
608
|
-
};
|
|
609
|
-
try {
|
|
610
|
-
await sbx.sandbox.runAgent(ctx.taskId, prompt, {
|
|
611
|
-
model,
|
|
612
|
-
thinking,
|
|
613
|
-
profile,
|
|
614
|
-
sandboxEnv,
|
|
615
|
-
agentCwd,
|
|
616
|
-
skillDirs: skillDirsInContainer,
|
|
617
|
-
webSearch: config.webSearch === true,
|
|
618
|
-
webSearchProvider: config.webSearchProvider,
|
|
619
|
-
onLine: (line) => {
|
|
620
|
-
if (!line.startsWith("{"))
|
|
621
|
-
return;
|
|
622
|
-
let record;
|
|
623
|
-
try {
|
|
624
|
-
record = JSON.parse(line);
|
|
625
|
-
}
|
|
626
|
-
catch {
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
acc.feed(record);
|
|
630
|
-
shim.feed(record);
|
|
631
|
-
recordPiEvent(record, {
|
|
632
|
-
includeContent: config.otel?.includeContent === true,
|
|
633
|
-
surface: "agent",
|
|
634
|
-
workflowName: config.telemetry?.workflowName,
|
|
635
|
-
phaseName: config.telemetry?.phaseName,
|
|
636
|
-
model,
|
|
637
|
-
});
|
|
638
|
-
if (!notifiedSessionId && ctx.onSessionId && record.type === "session" && typeof record.id === "string") {
|
|
639
|
-
notifiedSessionId = true;
|
|
640
|
-
ctx.onSessionId(record.id);
|
|
641
|
-
}
|
|
642
|
-
},
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
catch (err) {
|
|
646
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
647
|
-
const durationMs = Date.now() - startTime;
|
|
648
|
-
recordError("agent", err, { "sandbox.backend": "docker", model, success: false, stop_reason: "error_sandbox", "workflow.name": config.telemetry?.workflowName, "phase.name": config.telemetry?.phaseName });
|
|
649
|
-
recordExecutionMetrics("agent", { "sandbox.backend": "docker", model, success: false, stop_reason: "error_sandbox", durationMs });
|
|
650
|
-
const fallbackId = `exec-${basename(ctx.taskId)}`;
|
|
651
|
-
const synthesizedId = await shim
|
|
652
|
-
.finalizeWithFallback(emptyResult("error_sandbox", durationMs), fallbackId, msg)
|
|
653
|
-
.catch(() => null);
|
|
654
|
-
harvestArtifactsOut(artifacts);
|
|
655
|
-
await sbx.cleanup();
|
|
656
|
-
return {
|
|
657
|
-
success: false,
|
|
658
|
-
output: "",
|
|
659
|
-
turns: 0,
|
|
660
|
-
error: msg,
|
|
661
|
-
durationMs,
|
|
662
|
-
sessionId: synthesizedId ?? undefined,
|
|
663
|
-
stopReason: "error_sandbox",
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
harvestArtifactsOut(artifacts);
|
|
667
|
-
await sbx.cleanup();
|
|
668
|
-
const finalResult = await finalizeFromRunResult(acc.build(0), prompt, shim, startTime, acc.extensions(), acc.skills(), acc.toolError(), acc.endedOnToolCall());
|
|
669
|
-
recordExecutionMetrics("agent", {
|
|
670
|
-
"sandbox.backend": "docker",
|
|
671
|
-
model,
|
|
672
|
-
success: finalResult.success,
|
|
673
|
-
stop_reason: finalResult.stopReason,
|
|
674
|
-
durationMs: finalResult.durationMs,
|
|
675
|
-
costUsd: finalResult.costUsd,
|
|
676
|
-
inputTokens: finalResult.inputTokens,
|
|
677
|
-
outputTokens: finalResult.outputTokens,
|
|
678
|
-
"workflow.name": config.telemetry?.workflowName,
|
|
679
|
-
"phase.name": config.telemetry?.phaseName,
|
|
253
|
+
shim.finalize({
|
|
254
|
+
finalText: summary,
|
|
255
|
+
turns: 1,
|
|
256
|
+
costUsd: 0,
|
|
257
|
+
inputTokens: 0,
|
|
258
|
+
outputTokens: 0,
|
|
259
|
+
cacheReadInputTokens: 0,
|
|
260
|
+
cacheCreationInputTokens: 0,
|
|
261
|
+
stopReason: opts.exitCode === 0 ? "success" : "error_bash",
|
|
262
|
+
durationMs: opts.durationMs,
|
|
680
263
|
});
|
|
681
|
-
|
|
264
|
+
await shim.flush();
|
|
265
|
+
return shim.isInitialized ? sessionId : null;
|
|
682
266
|
}
|
|
683
267
|
/**
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
* Usage accounting accumulates each assistant `message_end`'s `usage`
|
|
690
|
-
* rather than trusting the terminal `usage_snapshot`. pi's snapshot is
|
|
691
|
-
* derived from `getSessionStats()`, which recomputes from the *current*
|
|
692
|
-
* in-memory message window — auto-compaction replaces those messages with
|
|
693
|
-
* a summary, so the snapshot reports zero tokens/cost/turns the moment a
|
|
694
|
-
* phase compacts. The per-message events fire at finalization (before any
|
|
695
|
-
* compaction rebuild), so summing them is compaction-proof. `bestStats()`
|
|
696
|
-
* prefers the accumulation and falls back to the snapshot only when no
|
|
697
|
-
* per-message usage was observed.
|
|
268
|
+
* Execute a deterministic command/script. Mirrors {@link executeDocker} /
|
|
269
|
+
* {@link executeInProcess} for sandbox setup but runs `runCommand` (docker) or
|
|
270
|
+
* `spawnSync` (gondolin/none) instead of an agent, and writes a session jsonl
|
|
271
|
+
* so the output is visible in the dashboard + CLI.
|
|
698
272
|
*/
|
|
699
|
-
export
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (
|
|
739
|
-
|
|
740
|
-
|
|
273
|
+
export async function executeCommand(spec, config, opts) {
|
|
274
|
+
const { taskId, stateDir, backend, ghEnv, prePopulate } = await prepareRun(config, opts);
|
|
275
|
+
const access = opts?.githubAccess;
|
|
276
|
+
const model = config.model || DEFAULT_MODEL;
|
|
277
|
+
const sessionsDir = resolveSessionsDir(config);
|
|
278
|
+
const timeoutSeconds = opts?.timeoutSeconds ?? 300;
|
|
279
|
+
const startTime = Date.now();
|
|
280
|
+
const displayPrompt = spec.kind === "bash" ? `$ ${spec.command}` : `${spec.runtime} script: ${spec.name}\n\n${spec.script}`;
|
|
281
|
+
const spanAttrs = safeSpanAttributes({
|
|
282
|
+
"agent.runtime": spec.kind,
|
|
283
|
+
"sandbox.backend": backend,
|
|
284
|
+
"task.id": taskId,
|
|
285
|
+
repo: access?.repo,
|
|
286
|
+
"github.profile": access?.profile,
|
|
287
|
+
unrestricted_egress: config.unrestrictedEgress === true,
|
|
288
|
+
"workflow.name": config.telemetry?.workflowName,
|
|
289
|
+
"phase.name": config.telemetry?.phaseName,
|
|
290
|
+
});
|
|
291
|
+
return withSpan("lastlight.command.execute", spanAttrs, async () => {
|
|
292
|
+
// Per-phase script-bundle dir, a workspace-root sibling of the skill bundle
|
|
293
|
+
// (and of any repo checkout) — so a `type: script` file is never written
|
|
294
|
+
// inside the target's git tree. `spec.name` is the (sanitized) phase name.
|
|
295
|
+
const scriptDir = spec.kind === "script" ? `${SCRIPT_BUNDLE_ROOT}/${spec.name}` : SCRIPT_BUNDLE_ROOT;
|
|
296
|
+
if (backend === "docker") {
|
|
297
|
+
const dnsIp = config.unrestrictedEgress
|
|
298
|
+
? (process.env.LASTLIGHT_DNS_OPEN || "172.30.0.11")
|
|
299
|
+
: (process.env.LASTLIGHT_DNS_STRICT || "172.30.0.10");
|
|
300
|
+
const imageName = config.sandboxImage === "qa" ? SANDBOX_IMAGE_QA : undefined;
|
|
301
|
+
const sbx = await createTaskSandbox({
|
|
302
|
+
taskId, stateDir, sandboxDir: config.sandboxDir, env: ghEnv, prePopulate, dnsIp, imageName,
|
|
303
|
+
});
|
|
304
|
+
if (!sbx) {
|
|
305
|
+
throw new Error("LASTLIGHT_SANDBOX=docker but no docker sandbox was available for command phase.");
|
|
306
|
+
}
|
|
307
|
+
const agentCwd = prePopulate ? `${DOCKER_WORKSPACE_DIR}/${prePopulate.repo}` : DOCKER_WORKSPACE_DIR;
|
|
308
|
+
try {
|
|
309
|
+
let command;
|
|
310
|
+
let toolName;
|
|
311
|
+
let toolInput;
|
|
312
|
+
if (spec.kind === "bash") {
|
|
313
|
+
command = spec.command;
|
|
314
|
+
toolName = "bash";
|
|
315
|
+
toolInput = { command: spec.command };
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
const { fileName, run } = scriptInvocation(spec);
|
|
319
|
+
mkdirSync(join(sbx.workDir, scriptDir), { recursive: true });
|
|
320
|
+
writeFileSync(join(sbx.workDir, scriptDir, fileName), spec.script);
|
|
321
|
+
const inContainer = `${DOCKER_WORKSPACE_DIR}/${scriptDir}/${fileName}`;
|
|
322
|
+
command = run(inContainer);
|
|
323
|
+
toolName = "bash";
|
|
324
|
+
toolInput = { command, runtime: spec.runtime };
|
|
741
325
|
}
|
|
742
|
-
|
|
326
|
+
const res = await sbx.sandbox.runCommand(taskId, command, {
|
|
327
|
+
cwd: agentCwd,
|
|
328
|
+
sandboxEnv: opts?.sandboxEnv,
|
|
329
|
+
timeoutSeconds,
|
|
330
|
+
onLine: () => { },
|
|
331
|
+
});
|
|
332
|
+
const durationMs = Date.now() - startTime;
|
|
333
|
+
const sessionId = opts?.writeSession === false ? null : await writeCommandSession({
|
|
334
|
+
sessionsDir, projectSlug: projectSlugForCwd(agentCwd), model,
|
|
335
|
+
displayPrompt, toolName, toolInput,
|
|
336
|
+
stdout: res.stdout, stderr: res.stderr, exitCode: res.exitCode, durationMs,
|
|
337
|
+
});
|
|
338
|
+
if (sessionId && opts?.onSessionId)
|
|
339
|
+
opts.onSessionId(sessionId);
|
|
340
|
+
return buildCommandResult(res, durationMs, sessionId);
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
await sbx.cleanup();
|
|
743
344
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
345
|
+
}
|
|
346
|
+
if (backend === "smol") {
|
|
347
|
+
if (!smolAvailable()) {
|
|
348
|
+
throw new Error("LASTLIGHT_SANDBOX=smol but the smolvm CLI is not available for command phase.");
|
|
748
349
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
350
|
+
const allowHosts = config.unrestrictedEgress
|
|
351
|
+
? null
|
|
352
|
+
: mergeAllowlist(DEFAULT_ALLOWLIST, config.otel?.enabled && config.otel.forwardToSandbox ? config.otel.collectorHosts : []);
|
|
353
|
+
// Boot first, then provision the probed share-backed dir (see executeSmol).
|
|
354
|
+
const workDir = setupTaskWorktree({ taskId, stateDir, sandboxDir: config.sandboxDir });
|
|
355
|
+
const sandbox = new SmolSandbox({ env: ghEnv, allowHosts });
|
|
356
|
+
const machine = await sandbox.create({ taskId, worktreePath: workDir });
|
|
357
|
+
const hostWs = machine.hostWorkspace;
|
|
358
|
+
if (prePopulate)
|
|
359
|
+
prePopulateWorkspace(hostWs, prePopulate);
|
|
360
|
+
const agentCwd = prePopulate ? `${SMOL_WORKSPACE_DIR}/${prePopulate.repo}` : SMOL_WORKSPACE_DIR;
|
|
361
|
+
try {
|
|
362
|
+
let command;
|
|
363
|
+
let toolName;
|
|
364
|
+
let toolInput;
|
|
365
|
+
if (spec.kind === "bash") {
|
|
366
|
+
command = spec.command;
|
|
367
|
+
toolName = "bash";
|
|
368
|
+
toolInput = { command: spec.command };
|
|
766
369
|
}
|
|
767
|
-
else
|
|
768
|
-
|
|
370
|
+
else {
|
|
371
|
+
const { fileName, run } = scriptInvocation(spec);
|
|
372
|
+
mkdirSync(join(hostWs, scriptDir), { recursive: true });
|
|
373
|
+
writeFileSync(join(hostWs, scriptDir, fileName), spec.script);
|
|
374
|
+
const inGuest = `${SMOL_WORKSPACE_DIR}/${scriptDir}/${fileName}`;
|
|
375
|
+
command = run(inGuest);
|
|
376
|
+
toolName = "bash";
|
|
377
|
+
toolInput = { command, runtime: spec.runtime };
|
|
769
378
|
}
|
|
770
|
-
|
|
379
|
+
const res = await sandbox.runCommand(taskId, command, {
|
|
380
|
+
cwd: agentCwd,
|
|
381
|
+
sandboxEnv: opts?.sandboxEnv,
|
|
382
|
+
timeoutSeconds,
|
|
383
|
+
onLine: () => { },
|
|
384
|
+
});
|
|
385
|
+
const durationMs = Date.now() - startTime;
|
|
386
|
+
const sessionId = opts?.writeSession === false ? null : await writeCommandSession({
|
|
387
|
+
sessionsDir, projectSlug: projectSlugForCwd(agentCwd), model,
|
|
388
|
+
displayPrompt, toolName, toolInput,
|
|
389
|
+
stdout: res.stdout, stderr: res.stderr, exitCode: res.exitCode, durationMs,
|
|
390
|
+
});
|
|
391
|
+
if (sessionId && opts?.onSessionId)
|
|
392
|
+
opts.onSessionId(sessionId);
|
|
393
|
+
return buildCommandResult(res, durationMs, sessionId);
|
|
771
394
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
if (r.isError === true) {
|
|
775
|
-
this.toolErrors = true;
|
|
776
|
-
// Keep the actual failure text (not just a boolean) so a run that
|
|
777
|
-
// ends in `error_tool` can report which tool failed and why —
|
|
778
|
-
// e.g. a provider `insufficient_quota` surfaced through an MCP
|
|
779
|
-
// call, or a bash command's stderr. Last error wins (it's the
|
|
780
|
-
// one that ended the run). truncate: tool output can be huge.
|
|
781
|
-
const raw = r.error ?? r.result ?? r.output;
|
|
782
|
-
const message = truncateForLog(typeof raw === "string" ? raw : safeStringify(raw), 4096);
|
|
783
|
-
const tool = typeof r.tool === "string"
|
|
784
|
-
? r.tool
|
|
785
|
-
: typeof r.toolName === "string"
|
|
786
|
-
? r.toolName
|
|
787
|
-
: undefined;
|
|
788
|
-
if (message)
|
|
789
|
-
this.lastToolError = { tool, message };
|
|
790
|
-
}
|
|
791
|
-
break;
|
|
792
|
-
case "agent_end":
|
|
793
|
-
this.agentEnded = true;
|
|
794
|
-
if (Array.isArray(r.messages))
|
|
795
|
-
this.messages = r.messages;
|
|
796
|
-
break;
|
|
797
|
-
case "usage_snapshot":
|
|
798
|
-
this.snapshotStats = r.stats;
|
|
799
|
-
break;
|
|
800
|
-
case "fatal_error":
|
|
801
|
-
this.fatalError = r.error;
|
|
802
|
-
break;
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
accumulateUsage(usage) {
|
|
806
|
-
if (!usage || typeof usage !== "object")
|
|
807
|
-
return;
|
|
808
|
-
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
809
|
-
this.msgInput += num(usage.input);
|
|
810
|
-
this.msgOutput += num(usage.output);
|
|
811
|
-
this.msgCacheRead += num(usage.cacheRead);
|
|
812
|
-
this.msgCacheWrite += num(usage.cacheWrite);
|
|
813
|
-
const cost = usage.cost;
|
|
814
|
-
if (cost && typeof cost === "object")
|
|
815
|
-
this.msgCost += num(cost.total);
|
|
816
|
-
}
|
|
817
|
-
/** Stats summed from per-message usage, or undefined if none was seen. */
|
|
818
|
-
accumulatedStats() {
|
|
819
|
-
const total = this.msgInput + this.msgOutput + this.msgCacheRead + this.msgCacheWrite;
|
|
820
|
-
if (this.assistantMessages === 0 && total === 0)
|
|
821
|
-
return undefined;
|
|
822
|
-
return {
|
|
823
|
-
userMessages: this.userMessages,
|
|
824
|
-
assistantMessages: this.assistantMessages,
|
|
825
|
-
toolCalls: this.toolCalls,
|
|
826
|
-
toolResults: this.toolResults,
|
|
827
|
-
tokens: {
|
|
828
|
-
input: this.msgInput,
|
|
829
|
-
output: this.msgOutput,
|
|
830
|
-
cacheRead: this.msgCacheRead,
|
|
831
|
-
cacheWrite: this.msgCacheWrite,
|
|
832
|
-
total,
|
|
833
|
-
},
|
|
834
|
-
cost: this.msgCost,
|
|
835
|
-
};
|
|
836
|
-
}
|
|
837
|
-
/**
|
|
838
|
-
* Prefer the per-message accumulation (compaction-proof) over pi's
|
|
839
|
-
* terminal `usage_snapshot`. Fall back to the snapshot only when the
|
|
840
|
-
* accumulation carries no token data — e.g. a provider that doesn't
|
|
841
|
-
* report per-message usage — so a non-compacted snapshot still wins.
|
|
842
|
-
*/
|
|
843
|
-
bestStats() {
|
|
844
|
-
const acc = this.accumulatedStats();
|
|
845
|
-
if (acc && acc.tokens.total > 0)
|
|
846
|
-
return acc;
|
|
847
|
-
return this.snapshotStats ?? acc;
|
|
848
|
-
}
|
|
849
|
-
build(exitCode) {
|
|
850
|
-
return {
|
|
851
|
-
exitCode: exitCode,
|
|
852
|
-
ok: exitCode === 0 && !this.fatalError,
|
|
853
|
-
agentEnded: this.agentEnded,
|
|
854
|
-
toolErrors: this.toolErrors,
|
|
855
|
-
fatalError: this.fatalError,
|
|
856
|
-
sessionId: this.sessionId,
|
|
857
|
-
finalText: this.finalText,
|
|
858
|
-
messages: this.messages,
|
|
859
|
-
stats: this.bestStats(),
|
|
860
|
-
records: [],
|
|
861
|
-
warnings: [],
|
|
862
|
-
};
|
|
863
|
-
}
|
|
864
|
-
/**
|
|
865
|
-
* Normalized extension status captured from `extension_status` events
|
|
866
|
-
* (file-search / github / web-search), or undefined if none reported.
|
|
867
|
-
* Decoupled from agentic-pi's `RunResult` type, which lags the runtime —
|
|
868
|
-
* the docker sandbox's agentic-pi emits file-search even when the harness's
|
|
869
|
-
* pinned `RunResult` type doesn't yet declare it.
|
|
870
|
-
*/
|
|
871
|
-
extensions() {
|
|
872
|
-
const out = {};
|
|
873
|
-
for (const [name, v] of Object.entries(this.ext)) {
|
|
874
|
-
if (v && typeof v.status === "string") {
|
|
875
|
-
out[name] = {
|
|
876
|
-
status: v.status,
|
|
877
|
-
...(typeof v.mode === "string" ? { mode: v.mode } : {}),
|
|
878
|
-
...(typeof v.provider === "string" ? { provider: v.provider } : {}),
|
|
879
|
-
...(typeof v.toolCount === "number" ? { toolCount: v.toolCount } : {}),
|
|
880
|
-
...(typeof v.reason === "string" ? { reason: v.reason } : {}),
|
|
881
|
-
};
|
|
395
|
+
finally {
|
|
396
|
+
await sandbox.destroy(taskId);
|
|
882
397
|
}
|
|
883
398
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
return undefined;
|
|
896
|
-
const skills = Array.isArray(s.skills)
|
|
897
|
-
? s.skills
|
|
898
|
-
.filter((x) => !!x && typeof x === "object")
|
|
899
|
-
.map((x) => ({
|
|
900
|
-
name: typeof x.name === "string" ? x.name : "",
|
|
901
|
-
source: typeof x.source === "string" ? x.source : "",
|
|
902
|
-
modelInvocable: x.modelInvocable === true,
|
|
903
|
-
}))
|
|
904
|
-
: [];
|
|
905
|
-
return {
|
|
906
|
-
status: s.status,
|
|
907
|
-
discovered: typeof s.discovered === "number" ? s.discovered : skills.length,
|
|
908
|
-
skills,
|
|
909
|
-
mappedPaths: Array.isArray(s.mappedPaths)
|
|
910
|
-
? s.mappedPaths.filter((p) => typeof p === "string")
|
|
911
|
-
: [],
|
|
912
|
-
noSkills: s.noSkills === true,
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
/**
|
|
916
|
-
* The last tool result that came back with `isError: true`, including the
|
|
917
|
-
* failure text — or undefined if no tool errored. This is what turns a
|
|
918
|
-
* bare `error_tool` stop reason into a human-readable cause.
|
|
919
|
-
*/
|
|
920
|
-
toolError() {
|
|
921
|
-
return this.lastToolError;
|
|
922
|
-
}
|
|
923
|
-
/**
|
|
924
|
-
* True iff the run's final assistant turn ended on a tool call — meaning
|
|
925
|
-
* the agent intended to keep going but the loop stopped before it could
|
|
926
|
-
* respond to the tool result and write its answer. The signal a run was
|
|
927
|
-
* truncated (step-limit) rather than genuinely finished.
|
|
928
|
-
*/
|
|
929
|
-
endedOnToolCall() {
|
|
930
|
-
return this.lastAssistantHadToolCall;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
// ── Helpers ─────────────────────────────────────────────────────────
|
|
934
|
-
/** Provider account/billing/auth failures pi may surface as error text. */
|
|
935
|
-
const ACCOUNT_ERROR_MARKERS = [
|
|
936
|
-
"credit balance",
|
|
937
|
-
"insufficient_quota",
|
|
938
|
-
"insufficient quota",
|
|
939
|
-
"rate limit",
|
|
940
|
-
"unauthorized",
|
|
941
|
-
"invalid_api_key",
|
|
942
|
-
];
|
|
943
|
-
/**
|
|
944
|
-
* Detect a provider account error (out of credit, quota, rate-limited, bad key)
|
|
945
|
-
* that pi may have surfaced as plain text rather than a hard failure.
|
|
946
|
-
*
|
|
947
|
-
* Critically, on a **successful** run we scan ONLY the genuine provider-error
|
|
948
|
-
* channel (`agentErrorMessage`, which `extractAgentError` already turns into a
|
|
949
|
-
* non-success stopReason when set — so it's empty here). The agent's own output
|
|
950
|
-
* (`finalText`) and tool results (`fatalError`, `toolError`) are NOT scanned on
|
|
951
|
-
* success: a legitimate `verify`/`qa-test` report or a `curl` probing a 401
|
|
952
|
-
* endpoint routinely contains "unauthorized" / "rate limit" as part of the task
|
|
953
|
-
* itself, and folding that in would wrongly fail a genuinely successful run
|
|
954
|
-
* (and drop its report). Only on a failed run do we fold those in to label why.
|
|
955
|
-
*/
|
|
956
|
-
export function detectAccountError(opts) {
|
|
957
|
-
const combined = [
|
|
958
|
-
opts.success ? undefined : opts.fatalErrorMessage,
|
|
959
|
-
opts.agentErrorMessage,
|
|
960
|
-
opts.success ? undefined : opts.finalText,
|
|
961
|
-
opts.success ? undefined : opts.toolErrorText,
|
|
962
|
-
]
|
|
963
|
-
.filter((s) => typeof s === "string" && s.length > 0)
|
|
964
|
-
.join("\n")
|
|
965
|
-
.toLowerCase();
|
|
966
|
-
return ACCOUNT_ERROR_MARKERS.some((m) => combined.includes(m));
|
|
967
|
-
}
|
|
968
|
-
function finalizeFromRunResult(result, _prompt, shim, startTime, extensions, skills, toolError, endedOnToolCall = false) {
|
|
969
|
-
const durationMs = Date.now() - startTime;
|
|
970
|
-
const stats = result.stats;
|
|
971
|
-
// pi-agent-core swallows provider API errors (insufficient_quota, rate
|
|
972
|
-
// limit, auth) inside `handleRunFailure`: it synthesizes an assistant
|
|
973
|
-
// message with stopReason "error" + errorMessage and emits a clean
|
|
974
|
-
// agent_end. Without this check the run would map to "success" and the
|
|
975
|
-
// workflow would silently advance with no output. See message scan below.
|
|
976
|
-
const agentError = extractAgentError(result);
|
|
977
|
-
let stopReason = agentError?.stopReason ?? mapStopReason(result);
|
|
978
|
-
// Truncation guard. A run that *would* map to "success" but whose final
|
|
979
|
-
// assistant turn ended on a tool call was cut off mid-task: the agent
|
|
980
|
-
// asked for a tool and the loop stopped before it could read the result
|
|
981
|
-
// and synthesize an answer (pi hit its internal step cap — agentic-pi
|
|
982
|
-
// v0.2.7 exposes no maxSteps knob to lift it). In that state `finalText`
|
|
983
|
-
// is the agent's "let me just check X" preamble, NOT an answer. Reclassify
|
|
984
|
-
// as a failure so the workflow fires `on_failure` instead of delivering
|
|
985
|
-
// the fragment as if it were the result.
|
|
986
|
-
const truncated = stopReason === "success" && !agentError && endedOnToolCall;
|
|
987
|
-
if (truncated)
|
|
988
|
-
stopReason = "error_truncated";
|
|
989
|
-
const success = stopReason === "success";
|
|
990
|
-
const truncationMessage = "Agent stopped mid-task before producing a final answer (hit the agent " +
|
|
991
|
-
"step limit). No answer was delivered.";
|
|
992
|
-
// A bare `error_tool` stop reason is useless on its own. Surface the
|
|
993
|
-
// failing tool's actual error text so the executions row and dashboard
|
|
994
|
-
// show *why* the run died (e.g. "Tool `bash` failed: insufficient_quota").
|
|
995
|
-
const toolErrorText = toolError
|
|
996
|
-
? toolError.tool
|
|
997
|
-
? `Tool \`${toolError.tool}\` failed: ${toolError.message}`
|
|
998
|
-
: toolError.message
|
|
999
|
-
: undefined;
|
|
1000
|
-
const inputTokens = stats?.tokens.input ?? 0;
|
|
1001
|
-
const outputTokens = stats?.tokens.output ?? 0;
|
|
1002
|
-
const cacheRead = stats?.tokens.cacheRead ?? 0;
|
|
1003
|
-
const cacheWrite = stats?.tokens.cacheWrite ?? 0;
|
|
1004
|
-
const costUsd = stats?.cost ?? 0;
|
|
1005
|
-
const turns = stats?.assistantMessages ?? 0;
|
|
1006
|
-
const costStr = costUsd > 0 ? `, $${costUsd.toFixed(4)}` : "";
|
|
1007
|
-
console.log(` [executor] Result: ${stopReason} (${turns} turns, ${Math.round(durationMs / 1000)}s${costStr})` +
|
|
1008
|
-
`${result.sessionId ? ` [session ${result.sessionId}]` : ""}`);
|
|
1009
|
-
shim.finalize({
|
|
1010
|
-
finalText: result.finalText,
|
|
1011
|
-
turns,
|
|
1012
|
-
costUsd,
|
|
1013
|
-
inputTokens,
|
|
1014
|
-
outputTokens,
|
|
1015
|
-
cacheReadInputTokens: cacheRead,
|
|
1016
|
-
cacheCreationInputTokens: cacheWrite,
|
|
1017
|
-
stopReason,
|
|
1018
|
-
durationMs,
|
|
1019
|
-
apiErrorMessage: agentError?.errorMessage ??
|
|
1020
|
-
(success ? undefined : (toolErrorText ?? (truncated ? truncationMessage : undefined))),
|
|
1021
|
-
});
|
|
1022
|
-
void shim.flush();
|
|
1023
|
-
const accountError = detectAccountError({
|
|
1024
|
-
success,
|
|
1025
|
-
fatalErrorMessage: result.fatalError?.message,
|
|
1026
|
-
agentErrorMessage: agentError?.errorMessage,
|
|
1027
|
-
finalText: result.finalText,
|
|
1028
|
-
toolErrorText,
|
|
1029
|
-
});
|
|
1030
|
-
const errorText = result.fatalError?.message ||
|
|
1031
|
-
agentError?.errorMessage ||
|
|
1032
|
-
toolErrorText ||
|
|
1033
|
-
(truncated ? truncationMessage : result.finalText) ||
|
|
1034
|
-
stopReason;
|
|
1035
|
-
if (!success || accountError) {
|
|
1036
|
-
if (accountError)
|
|
1037
|
-
console.error(` [executor] Account error: ${errorText}`);
|
|
1038
|
-
else
|
|
1039
|
-
console.error(` [executor] Run failed (${stopReason}): ${errorText}`);
|
|
1040
|
-
}
|
|
1041
|
-
return {
|
|
1042
|
-
success: success && !accountError,
|
|
1043
|
-
output: result.finalText,
|
|
1044
|
-
turns,
|
|
1045
|
-
error: success && !accountError ? undefined : errorText,
|
|
1046
|
-
durationMs,
|
|
1047
|
-
sessionId: result.sessionId,
|
|
1048
|
-
costUsd: costUsd > 0 ? costUsd : undefined,
|
|
1049
|
-
inputTokens: inputTokens || undefined,
|
|
1050
|
-
outputTokens: outputTokens || undefined,
|
|
1051
|
-
cacheReadInputTokens: cacheRead || undefined,
|
|
1052
|
-
cacheCreationInputTokens: cacheWrite || undefined,
|
|
1053
|
-
stopReason,
|
|
1054
|
-
extensions,
|
|
1055
|
-
skills,
|
|
1056
|
-
};
|
|
1057
|
-
}
|
|
1058
|
-
function mapStopReason(result) {
|
|
1059
|
-
if (result.fatalError)
|
|
1060
|
-
return "error_fatal";
|
|
1061
|
-
if (result.toolErrors && result.finalText.length === 0)
|
|
1062
|
-
return "error_tool";
|
|
1063
|
-
if (!result.ok)
|
|
1064
|
-
return `error_exit_${result.exitCode}`;
|
|
1065
|
-
if (result.agentEnded || result.finalText.length > 0)
|
|
1066
|
-
return "success";
|
|
1067
|
-
return "unknown";
|
|
1068
|
-
}
|
|
1069
|
-
/**
|
|
1070
|
-
* Scan agent_end's messages for a failed assistant turn. pi-agent-core's
|
|
1071
|
-
* `handleRunFailure` catches provider errors, synthesizes an assistant
|
|
1072
|
-
* message with stopReason "error"/"aborted" + errorMessage, and emits a
|
|
1073
|
-
* normal agent_end — so the only signal that the run actually failed
|
|
1074
|
-
* lives on the last assistant message.
|
|
1075
|
-
*/
|
|
1076
|
-
function extractAgentError(result) {
|
|
1077
|
-
if (!Array.isArray(result.messages))
|
|
1078
|
-
return undefined;
|
|
1079
|
-
for (let i = result.messages.length - 1; i >= 0; i--) {
|
|
1080
|
-
const m = result.messages[i];
|
|
1081
|
-
if (m?.role !== "assistant")
|
|
1082
|
-
continue;
|
|
1083
|
-
if (m.stopReason === "error" || m.stopReason === "aborted") {
|
|
1084
|
-
return {
|
|
1085
|
-
stopReason: m.stopReason === "aborted" ? "error_aborted" : "error_agent",
|
|
1086
|
-
errorMessage: m.errorMessage || m.stopReason,
|
|
1087
|
-
};
|
|
399
|
+
// gondolin / none — run on the host worktree via spawnSync (the same
|
|
400
|
+
// degraded model those backends already use; matches the pre-existing
|
|
401
|
+
// host-side until_bash behaviour).
|
|
402
|
+
const workDir = setupTaskWorktree({ taskId, stateDir, sandboxDir: config.sandboxDir, prePopulate });
|
|
403
|
+
const agentCwd = access?.prePopulateBranch ? join(workDir, access.repo) : workDir;
|
|
404
|
+
let command;
|
|
405
|
+
let toolName = "bash";
|
|
406
|
+
let toolInput;
|
|
407
|
+
if (spec.kind === "bash") {
|
|
408
|
+
command = spec.command;
|
|
409
|
+
toolInput = { command: spec.command };
|
|
1088
410
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
411
|
+
else {
|
|
412
|
+
const { fileName, run } = scriptInvocation(spec);
|
|
413
|
+
mkdirSync(join(workDir, scriptDir), { recursive: true });
|
|
414
|
+
const filePath = join(workDir, scriptDir, fileName);
|
|
415
|
+
writeFileSync(filePath, spec.script);
|
|
416
|
+
command = run(filePath);
|
|
417
|
+
toolInput = { command, runtime: spec.runtime };
|
|
418
|
+
}
|
|
419
|
+
const proc = spawnSync("sh", ["-c", command], {
|
|
420
|
+
cwd: agentCwd,
|
|
421
|
+
env: { ...process.env, ...ghEnv, ...(opts?.sandboxEnv ?? {}) },
|
|
422
|
+
encoding: "utf-8",
|
|
423
|
+
timeout: timeoutSeconds * 1000,
|
|
424
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
425
|
+
});
|
|
426
|
+
const durationMs = Date.now() - startTime;
|
|
427
|
+
const exitCode = proc.status ?? (proc.signal ? 124 : 1);
|
|
428
|
+
const res = { exitCode, stdout: proc.stdout ?? "", stderr: proc.stderr ?? "", timedOut: proc.signal === "SIGTERM" };
|
|
429
|
+
const sessionId = opts?.writeSession === false ? null : await writeCommandSession({
|
|
430
|
+
sessionsDir, projectSlug: projectSlugForCwd(agentCwd), model,
|
|
431
|
+
displayPrompt, toolName, toolInput,
|
|
432
|
+
stdout: res.stdout, stderr: res.stderr, exitCode: res.exitCode, durationMs,
|
|
433
|
+
});
|
|
434
|
+
if (sessionId && opts?.onSessionId)
|
|
435
|
+
opts.onSessionId(sessionId);
|
|
436
|
+
return buildCommandResult(res, durationMs, sessionId);
|
|
437
|
+
});
|
|
1110
438
|
}
|
|
1111
|
-
|
|
439
|
+
/** Map a raw command result onto the ExecutionResult contract (turns 0, no cost). */
|
|
440
|
+
function buildCommandResult(res, durationMs, sessionId) {
|
|
441
|
+
const success = res.exitCode === 0;
|
|
442
|
+
const combined = res.stderr ? `${res.stdout}${res.stdout && !res.stdout.endsWith("\n") ? "\n" : ""}${res.stderr}` : res.stdout;
|
|
443
|
+
// Strip the trailing newline so the value substitutes cleanly into a
|
|
444
|
+
// downstream command / `{{phaseOutputs.<name>}}` and can be forwarded as an
|
|
445
|
+
// `LL_OUT_<PHASE>` env var (mirrors archon's "trailing newline removed"). The
|
|
446
|
+
// full raw stdout/stderr is preserved verbatim in the session jsonl.
|
|
447
|
+
const output = combined.replace(/\n+$/, "");
|
|
1112
448
|
return {
|
|
1113
|
-
|
|
449
|
+
success,
|
|
450
|
+
output,
|
|
1114
451
|
turns: 0,
|
|
1115
|
-
costUsd: 0,
|
|
1116
|
-
inputTokens: 0,
|
|
1117
|
-
outputTokens: 0,
|
|
1118
|
-
cacheReadInputTokens: 0,
|
|
1119
|
-
cacheCreationInputTokens: 0,
|
|
1120
|
-
stopReason,
|
|
1121
452
|
durationMs,
|
|
1122
|
-
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function applyEnv(env) {
|
|
1126
|
-
const saved = {};
|
|
1127
|
-
for (const [k, v] of Object.entries(env)) {
|
|
1128
|
-
saved[k] = process.env[k];
|
|
1129
|
-
process.env[k] = v;
|
|
1130
|
-
}
|
|
1131
|
-
return () => {
|
|
1132
|
-
for (const [k, v] of Object.entries(saved)) {
|
|
1133
|
-
if (v === undefined)
|
|
1134
|
-
delete process.env[k];
|
|
1135
|
-
else
|
|
1136
|
-
process.env[k] = v;
|
|
1137
|
-
}
|
|
453
|
+
sessionId: sessionId ?? undefined,
|
|
454
|
+
error: success ? undefined : res.timedOut ? `command timed out after ${Math.round(durationMs / 1000)}s` : `command exited ${res.exitCode}`,
|
|
455
|
+
stopReason: success ? "success" : res.timedOut ? "error_timeout" : "error_bash",
|
|
1138
456
|
};
|
|
1139
457
|
}
|
|
1140
458
|
//# sourceMappingURL=agent-executor.js.map
|