xcoder-jarvis-agent 0.0.0-stage → 1.4.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 +243 -2
- package/dist/index.d.ts +27 -0
- package/dist/index.js +28 -0
- package/dist/jarvis/agent.d.ts +50 -0
- package/dist/jarvis/agent.js +305 -0
- package/dist/jarvis/argv.d.ts +30 -0
- package/dist/jarvis/argv.js +138 -0
- package/dist/jarvis/budget.d.ts +49 -0
- package/dist/jarvis/budget.js +83 -0
- package/dist/jarvis/chat/confirm.d.ts +23 -0
- package/dist/jarvis/chat/confirm.js +37 -0
- package/dist/jarvis/chat/lines.d.ts +41 -0
- package/dist/jarvis/chat/lines.js +93 -0
- package/dist/jarvis/chat/prompt.d.ts +10 -0
- package/dist/jarvis/chat/prompt.js +18 -0
- package/dist/jarvis/chat/session.d.ts +46 -0
- package/dist/jarvis/chat/session.js +121 -0
- package/dist/jarvis/chat/terminator.d.ts +14 -0
- package/dist/jarvis/chat/terminator.js +19 -0
- package/dist/jarvis/decision.d.ts +64 -0
- package/dist/jarvis/decision.js +116 -0
- package/dist/jarvis/exit.d.ts +17 -0
- package/dist/jarvis/exit.js +17 -0
- package/dist/jarvis/memory/store.d.ts +88 -0
- package/dist/jarvis/memory/store.js +189 -0
- package/dist/jarvis/prompt/assemble.d.ts +61 -0
- package/dist/jarvis/prompt/assemble.js +118 -0
- package/dist/jarvis/prompt/policy.d.ts +22 -0
- package/dist/jarvis/prompt/policy.js +80 -0
- package/dist/jarvis/prompt/skills.d.ts +69 -0
- package/dist/jarvis/prompt/skills.js +257 -0
- package/dist/jarvis/react-loop.d.ts +51 -0
- package/dist/jarvis/react-loop.js +160 -0
- package/dist/jarvis/telemetry/provenance.d.ts +54 -0
- package/dist/jarvis/telemetry/provenance.js +56 -0
- package/dist/jarvis/test-helpers.d.ts +14 -0
- package/dist/jarvis/test-helpers.js +69 -0
- package/dist/jarvis/tools/fs-tools.d.ts +28 -0
- package/dist/jarvis/tools/fs-tools.js +292 -0
- package/dist/jarvis/tools/memory-tools.d.ts +27 -0
- package/dist/jarvis/tools/memory-tools.js +83 -0
- package/dist/jarvis/tools/registry.d.ts +54 -0
- package/dist/jarvis/tools/registry.js +49 -0
- package/dist/jarvis/util/confirm.d.ts +28 -0
- package/dist/jarvis/util/confirm.js +54 -0
- package/dist/jarvis/util/kill.d.ts +10 -0
- package/dist/jarvis/util/kill.js +39 -0
- package/dist/jarvis/util/text.d.ts +74 -0
- package/dist/jarvis/util/text.js +190 -0
- package/dist/program.d.ts +2 -0
- package/dist/program.js +12 -0
- package/package.json +35 -3
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:46:14.714Z | xcoder:subtask code-st-53c7b5
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { maybeSeedSkillsHome } from "xcoder-dna";
|
|
6
|
+
import { byteLength, normalizeUntrusted, sha256 } from "../util/text.js";
|
|
7
|
+
export const DEFAULT_MAX_PACKS = 8;
|
|
8
|
+
export const DEFAULT_MAX_PACK_BYTES = 32_768;
|
|
9
|
+
export const DEFAULT_MAX_TOTAL_BYTES = 131_072;
|
|
10
|
+
/** Strip one or more matching quote pairs from a scalar. */
|
|
11
|
+
function unquote(value) {
|
|
12
|
+
const v = value.trim();
|
|
13
|
+
if (v.length >= 2) {
|
|
14
|
+
const first = v[0];
|
|
15
|
+
const last = v[v.length - 1];
|
|
16
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
17
|
+
return v.slice(1, -1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
/** Parse `[a, b, "c d"]` into ['a','b','c d']. Unknown shapes yield []. */
|
|
23
|
+
function parseInlineArray(value) {
|
|
24
|
+
const v = value.trim();
|
|
25
|
+
if (!v.startsWith("[") || !v.endsWith("]"))
|
|
26
|
+
return [];
|
|
27
|
+
const inner = v.slice(1, -1).trim();
|
|
28
|
+
if (inner.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
const out = [];
|
|
31
|
+
let current = "";
|
|
32
|
+
let quote = null;
|
|
33
|
+
for (const ch of inner) {
|
|
34
|
+
if (quote) {
|
|
35
|
+
if (ch === quote)
|
|
36
|
+
quote = null;
|
|
37
|
+
else
|
|
38
|
+
current += ch;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (ch === '"' || ch === "'") {
|
|
42
|
+
quote = ch;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (ch === ",") {
|
|
46
|
+
const item = current.trim();
|
|
47
|
+
if (item)
|
|
48
|
+
out.push(item);
|
|
49
|
+
current = "";
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
current += ch;
|
|
53
|
+
}
|
|
54
|
+
const last = current.trim();
|
|
55
|
+
if (last)
|
|
56
|
+
out.push(last);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Parse the leading `---` frontmatter block.
|
|
61
|
+
* Returns undefined when there is no block or no closing fence.
|
|
62
|
+
*/
|
|
63
|
+
export function parseFrontmatter(raw) {
|
|
64
|
+
const lines = raw.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
65
|
+
if (lines.length === 0 || lines[0].trim() !== "---")
|
|
66
|
+
return undefined;
|
|
67
|
+
let end = -1;
|
|
68
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
69
|
+
if (lines[i].trim() === "---") {
|
|
70
|
+
end = i;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (end === -1)
|
|
75
|
+
return undefined;
|
|
76
|
+
const meta = { triggers: [], requiresTools: [], composesWith: [] };
|
|
77
|
+
const header = lines.slice(1, end);
|
|
78
|
+
for (let i = 0; i < header.length; i += 1) {
|
|
79
|
+
const line = header[i];
|
|
80
|
+
if (line.trim().length === 0 || line.trimStart().startsWith("#"))
|
|
81
|
+
continue;
|
|
82
|
+
// Only top-level `key: value` entries; indented continuation belongs to
|
|
83
|
+
// the folded-scalar branch below.
|
|
84
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
|
|
85
|
+
if (!m)
|
|
86
|
+
continue;
|
|
87
|
+
const key = m[1].toLowerCase();
|
|
88
|
+
const rest = m[2];
|
|
89
|
+
if ([">", ">-", "|", "|-"].includes(rest.trim())) {
|
|
90
|
+
const collected = [];
|
|
91
|
+
let j = i + 1;
|
|
92
|
+
while (j < header.length && /^\s+\S/.test(header[j])) {
|
|
93
|
+
collected.push(header[j].trim());
|
|
94
|
+
j += 1;
|
|
95
|
+
}
|
|
96
|
+
i = j - 1;
|
|
97
|
+
const folded = collected.join(rest.trim().startsWith("|") ? "\n" : " ").trim();
|
|
98
|
+
if (key === "description")
|
|
99
|
+
meta.description = folded;
|
|
100
|
+
else if (key === "role")
|
|
101
|
+
meta.role = folded;
|
|
102
|
+
else if (key === "name")
|
|
103
|
+
meta.name = folded;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const value = rest.trim();
|
|
107
|
+
if (key === "name")
|
|
108
|
+
meta.name = unquote(value);
|
|
109
|
+
else if (key === "role")
|
|
110
|
+
meta.role = unquote(value);
|
|
111
|
+
else if (key === "description")
|
|
112
|
+
meta.description = unquote(value);
|
|
113
|
+
else if (key === "version")
|
|
114
|
+
meta.version = unquote(value);
|
|
115
|
+
else if (key === "triggers")
|
|
116
|
+
meta.triggers = parseInlineArray(value);
|
|
117
|
+
else if (key === "requires_tools")
|
|
118
|
+
meta.requiresTools = parseInlineArray(value);
|
|
119
|
+
else if (key === "composes_with")
|
|
120
|
+
meta.composesWith = parseInlineArray(value);
|
|
121
|
+
// Unknown keys are dropped rather than carried into the prompt.
|
|
122
|
+
}
|
|
123
|
+
return { meta, body: lines.slice(end + 1).join("\n") };
|
|
124
|
+
}
|
|
125
|
+
async function readPack(file, dirName, maxPackBytes) {
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
const buf = await fsp.readFile(file);
|
|
129
|
+
if (buf.byteLength > maxPackBytes) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
skipped: { name: dirName, reason: `pack exceeds ${maxPackBytes} bytes` },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
raw = buf.toString("utf8");
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return { ok: false, skipped: { name: dirName, reason: "pack could not be read" } };
|
|
139
|
+
}
|
|
140
|
+
const parsed = parseFrontmatter(raw);
|
|
141
|
+
if (!parsed) {
|
|
142
|
+
return { ok: false, skipped: { name: dirName, reason: "no frontmatter block" } };
|
|
143
|
+
}
|
|
144
|
+
const name = parsed.meta.name ?? dirName;
|
|
145
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name)) {
|
|
146
|
+
return { ok: false, skipped: { name: dirName, reason: "invalid pack name" } };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
pack: {
|
|
151
|
+
name,
|
|
152
|
+
role: parsed.meta.role,
|
|
153
|
+
description: parsed.meta.description,
|
|
154
|
+
triggers: parsed.meta.triggers,
|
|
155
|
+
version: parsed.meta.version,
|
|
156
|
+
requiresTools: parsed.meta.requiresTools,
|
|
157
|
+
composesWith: parsed.meta.composesWith,
|
|
158
|
+
body: normalizeUntrusted(parsed.body, maxPackBytes),
|
|
159
|
+
path: file,
|
|
160
|
+
sha256: sha256(raw),
|
|
161
|
+
bytes: byteLength(raw),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async function listPacks(root) {
|
|
166
|
+
let entries;
|
|
167
|
+
try {
|
|
168
|
+
entries = await fsp.readdir(root, { withFileTypes: true });
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const d of dirs) {
|
|
176
|
+
const file = path.join(root, d, "SKILL.md");
|
|
177
|
+
try {
|
|
178
|
+
const st = await fsp.stat(file);
|
|
179
|
+
if (st.isFile())
|
|
180
|
+
out.push(d);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* not a pack directory */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/** `~/.xcoder/skills` — mirrors dna's helper without importing past the barrel. */
|
|
189
|
+
function userSkillsDir(home) {
|
|
190
|
+
return path.join(home, ".xcoder", "skills");
|
|
191
|
+
}
|
|
192
|
+
function bundledSkillsDir(workspaceRoot) {
|
|
193
|
+
return path.join(workspaceRoot, "packages", "xcoder-dna", "skills");
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Candidate roots in priority order. The first root that yields at least one
|
|
197
|
+
* parseable pack wins, so a user copy shadows the bundled packs wholesale
|
|
198
|
+
* instead of silently merging two versions of the same skill.
|
|
199
|
+
*/
|
|
200
|
+
async function candidateRoots(o) {
|
|
201
|
+
if (o.skillsRoot)
|
|
202
|
+
return [o.skillsRoot];
|
|
203
|
+
const env = o.env ?? process.env;
|
|
204
|
+
const home = env["HOME"] || env["USERPROFILE"] || os.homedir();
|
|
205
|
+
const out = [];
|
|
206
|
+
const override = env["XCODER_SKILLS_DIR"];
|
|
207
|
+
if (override && override.trim().length > 0)
|
|
208
|
+
out.push(path.resolve(override.trim()));
|
|
209
|
+
out.push(path.join(o.workspaceRoot, ".xcoder", "skills"));
|
|
210
|
+
// Seeding is best-effort and never throws; awaiting it means a fresh clone
|
|
211
|
+
// still finds packs on the very first run.
|
|
212
|
+
try {
|
|
213
|
+
await maybeSeedSkillsHome({ homeDir: home });
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* discovery must not fail because seeding did */
|
|
217
|
+
}
|
|
218
|
+
out.push(userSkillsDir(home));
|
|
219
|
+
out.push(bundledSkillsDir(o.workspaceRoot));
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
export async function discoverSkills(o) {
|
|
223
|
+
const maxPacks = o.maxPacks ?? DEFAULT_MAX_PACKS;
|
|
224
|
+
const maxPackBytes = o.maxPackBytes ?? DEFAULT_MAX_PACK_BYTES;
|
|
225
|
+
const maxTotalBytes = o.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
|
226
|
+
const allow = o.allow && o.allow.length > 0 ? new Set(o.allow.map((s) => s.toLowerCase())) : undefined;
|
|
227
|
+
for (const root of await candidateRoots(o)) {
|
|
228
|
+
const dirs = await listPacks(root);
|
|
229
|
+
if (dirs.length === 0)
|
|
230
|
+
continue;
|
|
231
|
+
const packs = [];
|
|
232
|
+
const skipped = [];
|
|
233
|
+
let total = 0;
|
|
234
|
+
for (const dir of dirs) {
|
|
235
|
+
if (packs.length >= maxPacks) {
|
|
236
|
+
skipped.push({ name: dir, reason: `pack cap reached (${maxPacks})` });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const result = await readPack(path.join(root, dir, "SKILL.md"), dir, maxPackBytes);
|
|
240
|
+
if (!result.ok) {
|
|
241
|
+
skipped.push(result.skipped);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (total + result.pack.bytes > maxTotalBytes) {
|
|
245
|
+
skipped.push({ name: result.pack.name, reason: `total byte cap reached (${maxTotalBytes})` });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
total += result.pack.bytes;
|
|
249
|
+
packs.push(result.pack);
|
|
250
|
+
}
|
|
251
|
+
const filtered = allow ? packs.filter((p) => allow.has(p.name.toLowerCase())) : packs;
|
|
252
|
+
if (filtered.length === 0)
|
|
253
|
+
continue;
|
|
254
|
+
return { packs: filtered, skipped, root };
|
|
255
|
+
}
|
|
256
|
+
return { packs: [], skipped: [], root: undefined };
|
|
257
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { LlmMessage } from "xcoder-dna";
|
|
2
|
+
import { type RunBudget } from "./budget.js";
|
|
3
|
+
import type { AssembledPrompt } from "./prompt/assemble.js";
|
|
4
|
+
import type { JarvisOutcome } from "./telemetry/provenance.js";
|
|
5
|
+
import type { ToolContext, ToolObservation, ToolRegistry } from "./tools/registry.js";
|
|
6
|
+
/**
|
|
7
|
+
* The ReAct loop, owned in-package (design §2.4, tradeoff T1).
|
|
8
|
+
*
|
|
9
|
+
* The shared `GenesisReActOrchestrator` in xcoder-dna could not be reused: its
|
|
10
|
+
* tool registry is internal and non-injectable, which would mean shipping an
|
|
11
|
+
* agent whose tool surface we do not control. Six invariants hold here, each
|
|
12
|
+
* covered by a test:
|
|
13
|
+
* 1. iterations never exceed `maxIterations` (clamped to 8 by the caller);
|
|
14
|
+
* 2. every reply is parsed before any side effect occurs;
|
|
15
|
+
* 3. a tool not in the registry allowlist is refused in code and fed back as
|
|
16
|
+
* an error observation — it is never dispatched;
|
|
17
|
+
* 4. `isKilled()` is checked before each LLM call and each dispatch;
|
|
18
|
+
* 5. budget exhaustion yields `budget_exceeded` with no partial answer;
|
|
19
|
+
* 6. the kill-switch AbortSignal is passed to the tool context.
|
|
20
|
+
*/
|
|
21
|
+
export declare const MAX_ITERATIONS: 8;
|
|
22
|
+
export interface LlmCompleter {
|
|
23
|
+
complete(systemPrompt: string, transcript: LlmMessage[]): Promise<{
|
|
24
|
+
text: string;
|
|
25
|
+
usage: {
|
|
26
|
+
inputTokens: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
};
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export interface ReactLoopInput {
|
|
32
|
+
task: string;
|
|
33
|
+
systemPrompt: AssembledPrompt;
|
|
34
|
+
client: LlmCompleter;
|
|
35
|
+
registry: ToolRegistry;
|
|
36
|
+
toolContext: ToolContext;
|
|
37
|
+
maxIterations: number;
|
|
38
|
+
budget: RunBudget;
|
|
39
|
+
isKilled: () => boolean;
|
|
40
|
+
/** Called after each successful dispatch, for provenance. */
|
|
41
|
+
onToolUsed?: (name: string, observation: ToolObservation) => void;
|
|
42
|
+
}
|
|
43
|
+
export interface ReactLoopOutput {
|
|
44
|
+
outcome: JarvisOutcome;
|
|
45
|
+
answer?: string;
|
|
46
|
+
iterations: number;
|
|
47
|
+
transcript: ReadonlyArray<LlmMessage>;
|
|
48
|
+
error?: string;
|
|
49
|
+
killReason?: "env" | "stop-file";
|
|
50
|
+
}
|
|
51
|
+
export declare function runReactLoop(i: ReactLoopInput): Promise<ReactLoopOutput>;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { BudgetExceeded } from "./budget.js";
|
|
2
|
+
import { parseDecision, ReactParseError } from "./decision.js";
|
|
3
|
+
import { wrapObservation } from "./prompt/assemble.js";
|
|
4
|
+
/**
|
|
5
|
+
* The ReAct loop, owned in-package (design §2.4, tradeoff T1).
|
|
6
|
+
*
|
|
7
|
+
* The shared `GenesisReActOrchestrator` in xcoder-dna could not be reused: its
|
|
8
|
+
* tool registry is internal and non-injectable, which would mean shipping an
|
|
9
|
+
* agent whose tool surface we do not control. Six invariants hold here, each
|
|
10
|
+
* covered by a test:
|
|
11
|
+
* 1. iterations never exceed `maxIterations` (clamped to 8 by the caller);
|
|
12
|
+
* 2. every reply is parsed before any side effect occurs;
|
|
13
|
+
* 3. a tool not in the registry allowlist is refused in code and fed back as
|
|
14
|
+
* an error observation — it is never dispatched;
|
|
15
|
+
* 4. `isKilled()` is checked before each LLM call and each dispatch;
|
|
16
|
+
* 5. budget exhaustion yields `budget_exceeded` with no partial answer;
|
|
17
|
+
* 6. the kill-switch AbortSignal is passed to the tool context.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_ITERATIONS = 8;
|
|
20
|
+
const CORRECTIVE_NUDGE = "Your previous reply could not be used: it was not a single JSON object " +
|
|
21
|
+
"matching the required shape. Reply again with exactly one JSON object and " +
|
|
22
|
+
"no other text. Do not explain, and do not repeat the rejected text.";
|
|
23
|
+
function transcriptWith(transcript, role, content) {
|
|
24
|
+
return [...transcript, { role, content }];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Execute one parsed action. The allowlist is enforced by `registry.get()`,
|
|
28
|
+
* which returns undefined for anything not registered AND allowed — so an
|
|
29
|
+
* off-list name cannot reach `execute()` at all.
|
|
30
|
+
*/
|
|
31
|
+
async function dispatch(input, decision) {
|
|
32
|
+
const descriptor = input.registry.get(decision.tool);
|
|
33
|
+
if (!descriptor) {
|
|
34
|
+
return {
|
|
35
|
+
dispatched: false,
|
|
36
|
+
observation: {
|
|
37
|
+
ok: false,
|
|
38
|
+
summary: `refused: tool '${decision.tool}' is not in this run's allowlist (${input.registry
|
|
39
|
+
.allowlist()
|
|
40
|
+
.join(", ")})`,
|
|
41
|
+
bytes: 0,
|
|
42
|
+
truncated: false,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const parsedArgs = descriptor.argsSchema.safeParse(decision.args);
|
|
47
|
+
if (!parsedArgs.success) {
|
|
48
|
+
const issue = parsedArgs.error.issues[0];
|
|
49
|
+
return {
|
|
50
|
+
dispatched: false,
|
|
51
|
+
observation: {
|
|
52
|
+
ok: false,
|
|
53
|
+
summary: `refused: arguments for '${decision.tool}' did not match its schema (${issue?.path.join(".") || "(root)"}: ${issue?.message ?? "invalid"})`,
|
|
54
|
+
bytes: 0,
|
|
55
|
+
truncated: false,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const observation = await descriptor.execute(parsedArgs.data, input.toolContext);
|
|
61
|
+
return { dispatched: true, observation };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return {
|
|
65
|
+
dispatched: true,
|
|
66
|
+
observation: {
|
|
67
|
+
ok: false,
|
|
68
|
+
summary: `tool '${decision.tool}' failed while executing`,
|
|
69
|
+
bytes: 0,
|
|
70
|
+
truncated: false,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function runReactLoop(i) {
|
|
76
|
+
const maxIterations = Math.min(MAX_ITERATIONS, Math.max(1, i.maxIterations));
|
|
77
|
+
let transcript = [{ role: "user", content: i.task }];
|
|
78
|
+
let iterations = 0;
|
|
79
|
+
let consecutiveParseFailures = 0;
|
|
80
|
+
while (iterations < maxIterations) {
|
|
81
|
+
// (4) kill switch, before the LLM call.
|
|
82
|
+
if (i.isKilled()) {
|
|
83
|
+
return { outcome: "aborted", iterations, transcript, error: "kill switch tripped" };
|
|
84
|
+
}
|
|
85
|
+
// (5) budget, before spending anything.
|
|
86
|
+
try {
|
|
87
|
+
i.budget.check();
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err instanceof BudgetExceeded) {
|
|
91
|
+
return { outcome: "budget_exceeded", iterations, transcript, error: err.message };
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
let reply;
|
|
96
|
+
try {
|
|
97
|
+
reply = await i.client.complete(i.systemPrompt.system, transcript);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
const message = err instanceof Error ? err.message.slice(0, 300) : "model call failed";
|
|
101
|
+
return { outcome: "error", iterations, transcript, error: message };
|
|
102
|
+
}
|
|
103
|
+
iterations += 1;
|
|
104
|
+
try {
|
|
105
|
+
i.budget.chargeUsage(reply.usage);
|
|
106
|
+
i.budget.countIteration();
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err instanceof BudgetExceeded) {
|
|
110
|
+
return { outcome: "budget_exceeded", iterations, transcript, error: err.message };
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
// (2) parse BEFORE any side effect.
|
|
115
|
+
let decision;
|
|
116
|
+
try {
|
|
117
|
+
decision = parseDecision(reply.text);
|
|
118
|
+
consecutiveParseFailures = 0;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
consecutiveParseFailures += 1;
|
|
122
|
+
const detail = err instanceof ReactParseError ? err.message : "unparseable reply";
|
|
123
|
+
if (consecutiveParseFailures >= 2) {
|
|
124
|
+
return {
|
|
125
|
+
outcome: "error",
|
|
126
|
+
iterations,
|
|
127
|
+
transcript,
|
|
128
|
+
error: `model reply could not be parsed twice (${detail})`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Observed as data, never as trusted instruction.
|
|
132
|
+
transcript = transcriptWith(transcriptWith(transcript, "assistant", "[reply rejected: not a valid decision object]"), "user", CORRECTIVE_NUDGE);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
transcript = transcriptWith(transcript, "assistant", reply.text);
|
|
136
|
+
if (decision.kind === "final") {
|
|
137
|
+
return {
|
|
138
|
+
outcome: "answered",
|
|
139
|
+
answer: decision.answer,
|
|
140
|
+
iterations,
|
|
141
|
+
transcript,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// (4) kill switch again, before dispatching a tool.
|
|
145
|
+
if (i.isKilled()) {
|
|
146
|
+
return { outcome: "aborted", iterations, transcript, error: "kill switch tripped" };
|
|
147
|
+
}
|
|
148
|
+
// (3) allowlist + args schema, enforced in code.
|
|
149
|
+
const { observation, dispatched } = await dispatch(i, decision);
|
|
150
|
+
if (dispatched)
|
|
151
|
+
i.onToolUsed?.(decision.tool, observation);
|
|
152
|
+
transcript = transcriptWith(transcript, "user", wrapObservation(decision.tool, observation.summary));
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
outcome: "iteration_limit",
|
|
156
|
+
iterations,
|
|
157
|
+
transcript,
|
|
158
|
+
error: `reached the ${maxIterations}-iteration limit without a final answer`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type JarvisOutcome = "answered" | "iteration_limit" | "budget_exceeded" | "aborted" | "error";
|
|
2
|
+
export interface ProvenanceRecord {
|
|
3
|
+
v: 1;
|
|
4
|
+
runId: string;
|
|
5
|
+
ts: string;
|
|
6
|
+
agent: string;
|
|
7
|
+
version: string;
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
session: string;
|
|
11
|
+
policyVersion: string;
|
|
12
|
+
policySha256: string;
|
|
13
|
+
skills: Array<{
|
|
14
|
+
name: string;
|
|
15
|
+
sha256: string;
|
|
16
|
+
}>;
|
|
17
|
+
skippedSkills: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}>;
|
|
21
|
+
toolAllowlist: readonly string[];
|
|
22
|
+
toolsUsed: Array<{
|
|
23
|
+
name: string;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
bytes: number;
|
|
26
|
+
}>;
|
|
27
|
+
iterations: number;
|
|
28
|
+
outcome: JarvisOutcome;
|
|
29
|
+
usage: {
|
|
30
|
+
inputTokens: number;
|
|
31
|
+
outputTokens: number;
|
|
32
|
+
};
|
|
33
|
+
memoryWrites: number;
|
|
34
|
+
redactions: number;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
export declare const RETENTION_DAYS = 90;
|
|
38
|
+
/**
|
|
39
|
+
* Audit record for one run (CORE-08, PRM-10, AI-12).
|
|
40
|
+
*
|
|
41
|
+
* The schema itself is the privacy control: it has no field that can hold
|
|
42
|
+
* prompt text, model replies, file contents, tokens or keys. What is recorded
|
|
43
|
+
* is identifiers, hashes and counts — enough to reconstruct which prompt and
|
|
44
|
+
* which policy produced which outcome, without retaining user data.
|
|
45
|
+
*
|
|
46
|
+
* The error string is the one free-text field; it is redacted and capped
|
|
47
|
+
* before it is written.
|
|
48
|
+
*/
|
|
49
|
+
export declare function writeProvenance(dir: string, r: ProvenanceRecord): Promise<string>;
|
|
50
|
+
/**
|
|
51
|
+
* Delete run logs older than the retention window. Best-effort and never
|
|
52
|
+
* throws: pruning is housekeeping, and a failed prune must not fail a run.
|
|
53
|
+
*/
|
|
54
|
+
export declare function pruneProvenance(dir: string, now?: Date): Promise<number>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:47:00.711Z | xcoder:subtask code-st-53c7b5
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { redact, writeFilePrivate } from "../util/text.js";
|
|
5
|
+
export const RETENTION_DAYS = 90;
|
|
6
|
+
/**
|
|
7
|
+
* Audit record for one run (CORE-08, PRM-10, AI-12).
|
|
8
|
+
*
|
|
9
|
+
* The schema itself is the privacy control: it has no field that can hold
|
|
10
|
+
* prompt text, model replies, file contents, tokens or keys. What is recorded
|
|
11
|
+
* is identifiers, hashes and counts — enough to reconstruct which prompt and
|
|
12
|
+
* which policy produced which outcome, without retaining user data.
|
|
13
|
+
*
|
|
14
|
+
* The error string is the one free-text field; it is redacted and capped
|
|
15
|
+
* before it is written.
|
|
16
|
+
*/
|
|
17
|
+
export async function writeProvenance(dir, r) {
|
|
18
|
+
const record = {
|
|
19
|
+
...r,
|
|
20
|
+
error: r.error === undefined ? undefined : redact(r.error).text.slice(0, 500),
|
|
21
|
+
};
|
|
22
|
+
const file = path.join(dir, `${r.runId}.jsonl`);
|
|
23
|
+
await writeFilePrivate(file, JSON.stringify(record) + "\n");
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Delete run logs older than the retention window. Best-effort and never
|
|
28
|
+
* throws: pruning is housekeeping, and a failed prune must not fail a run.
|
|
29
|
+
*/
|
|
30
|
+
export async function pruneProvenance(dir, now = new Date()) {
|
|
31
|
+
const cutoff = now.getTime() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
32
|
+
let removed = 0;
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (!e.isFile() || !e.name.endsWith(".jsonl"))
|
|
42
|
+
continue;
|
|
43
|
+
const full = path.join(dir, e.name);
|
|
44
|
+
try {
|
|
45
|
+
const st = await fsp.stat(full);
|
|
46
|
+
if (st.mtimeMs < cutoff) {
|
|
47
|
+
await fsp.unlink(full);
|
|
48
|
+
removed += 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* skip unreadable entries */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return removed;
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { MemoryStore } from "./memory/store.js";
|
|
2
|
+
import { assembleSystemPrompt, type AssembledPrompt } from "./prompt/assemble.js";
|
|
3
|
+
import { type ToolDescriptor, type ToolObservation } from "./tools/registry.js";
|
|
4
|
+
import type { LlmCompleter, ReactLoopInput } from "./react-loop.js";
|
|
5
|
+
/** Shared fixtures for the jarvis test suite. Not part of the public barrel. */
|
|
6
|
+
export declare const TEST_WORKSPACE: string;
|
|
7
|
+
export declare function promptFor(overrides?: Partial<Parameters<typeof assembleSystemPrompt>[0]>): AssembledPrompt;
|
|
8
|
+
export declare function makeMemory(rootDir?: string): MemoryStore;
|
|
9
|
+
export declare function stubClient(reply: () => string): LlmCompleter;
|
|
10
|
+
/** A tool with a real zod args schema, so schema validation is genuinely exercised. */
|
|
11
|
+
export declare function fakeTool(name: string, execute: () => Promise<ToolObservation>): ToolDescriptor<Record<string, unknown>>;
|
|
12
|
+
export declare function baseInput(overrides?: Partial<ReactLoopInput>): ReactLoopInput;
|
|
13
|
+
export declare function finalReply(answer: string): string;
|
|
14
|
+
export declare function actionReply(tool: string, args?: Record<string, unknown>): string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:48:23.033Z | xcoder:subtask code-st-53c7b5
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { RunBudget } from "./budget.js";
|
|
4
|
+
import { MemoryStore } from "./memory/store.js";
|
|
5
|
+
import { assembleSystemPrompt } from "./prompt/assemble.js";
|
|
6
|
+
import { ALLOWED_TOOLS, createToolRegistry } from "./tools/registry.js";
|
|
7
|
+
import { alwaysDeny } from "./util/confirm.js";
|
|
8
|
+
/** Shared fixtures for the jarvis test suite. Not part of the public barrel. */
|
|
9
|
+
export const TEST_WORKSPACE = process.cwd();
|
|
10
|
+
export function promptFor(overrides = {}) {
|
|
11
|
+
return assembleSystemPrompt({
|
|
12
|
+
agentName: "jarvis-agent",
|
|
13
|
+
directive: "test task",
|
|
14
|
+
skills: [],
|
|
15
|
+
window: [],
|
|
16
|
+
toolAllowlist: [...ALLOWED_TOOLS],
|
|
17
|
+
toolManifest: "- read_file: read",
|
|
18
|
+
now: new Date("2026-01-01T00:00:00Z"),
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function makeMemory(rootDir = "/tmp/jarvis-test") {
|
|
23
|
+
return new MemoryStore({ rootDir, scope: { workspaceId: "ws-test", userId: "u-test" } });
|
|
24
|
+
}
|
|
25
|
+
export function stubClient(reply) {
|
|
26
|
+
return {
|
|
27
|
+
complete: async () => ({ text: reply(), usage: { inputTokens: 10, outputTokens: 5 } }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** A tool with a real zod args schema, so schema validation is genuinely exercised. */
|
|
31
|
+
export function fakeTool(name, execute) {
|
|
32
|
+
return {
|
|
33
|
+
name,
|
|
34
|
+
description: `fake ${name}`,
|
|
35
|
+
risk: "read",
|
|
36
|
+
argsSchema: z.object({}).passthrough().transform((v) => v),
|
|
37
|
+
execute: () => execute(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function baseInput(overrides = {}) {
|
|
41
|
+
return {
|
|
42
|
+
task: "do something",
|
|
43
|
+
systemPrompt: promptFor(),
|
|
44
|
+
client: stubClient(() => JSON.stringify({ kind: "final", thought: "t", answer: "done" })),
|
|
45
|
+
registry: createToolRegistry({ tools: [] }),
|
|
46
|
+
toolContext: {
|
|
47
|
+
workspaceRoot: TEST_WORKSPACE,
|
|
48
|
+
memory: makeMemory(),
|
|
49
|
+
session: "s1",
|
|
50
|
+
confirm: alwaysDeny,
|
|
51
|
+
limits: { maxReadBytes: 1024, maxResults: 10 },
|
|
52
|
+
},
|
|
53
|
+
maxIterations: 8,
|
|
54
|
+
budget: new RunBudget({
|
|
55
|
+
maxIterations: 8,
|
|
56
|
+
maxInputTokens: 1_000_000,
|
|
57
|
+
maxOutputTokens: 1_000_000,
|
|
58
|
+
maxWallMs: 60_000,
|
|
59
|
+
}),
|
|
60
|
+
isKilled: () => false,
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function finalReply(answer) {
|
|
65
|
+
return JSON.stringify({ kind: "final", thought: "t", answer });
|
|
66
|
+
}
|
|
67
|
+
export function actionReply(tool, args = {}) {
|
|
68
|
+
return JSON.stringify({ kind: "action", thought: "t", tool, args });
|
|
69
|
+
}
|