akemon 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/cli.js +169 -14
- package/dist/server.js +187 -2
- package/dist/software-agent-memory.js +141 -0
- package/dist/software-agent-peripheral.js +297 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,11 +164,16 @@ akemon serve --name my-agent --engine claude
|
|
|
164
164
|
|
|
165
165
|
# In another terminal, ask the local software peripheral to work in the repo
|
|
166
166
|
akemon software-agent "Add one focused test and run the relevant test command."
|
|
167
|
+
|
|
168
|
+
# Review recent software-agent runs
|
|
169
|
+
akemon software-agent-tasks --limit 5
|
|
167
170
|
```
|
|
168
171
|
|
|
169
172
|
This is different from `--engine`: engines are replaceable compute, while software agents are external software bodies with their own repo context, skills, tools, and execution loop.
|
|
170
173
|
|
|
171
|
-
Current Batch 5 status: the Codex integration uses `codex exec` as a one-shot baseline, not a true persistent interactive session yet. It is owner-only, local-only, one task at a time, and every call is wrapped in an explicit task envelope with workdir, memory scope, risk level, allowed actions, and forbidden actions.
|
|
174
|
+
Current Batch 5 status: the Codex integration uses `codex exec` as a one-shot baseline, not a true persistent interactive session yet. It is owner-only, local-only, one task at a time, streams local stdout/stderr by default, and every call is wrapped in an explicit task envelope with workdir, memory scope, risk level, allowed actions, and forbidden actions.
|
|
175
|
+
|
|
176
|
+
Software-agent tasks default to the `akemon serve` workdir boundary. Use `--allow-outside-workdir` only when you explicitly want the software agent to run outside that root. Each run is recorded under `.akemon/agents/<name>/software-agent/tasks/` with the envelope, result, output summaries, and git worktree status.
|
|
172
177
|
|
|
173
178
|
## Serve Options
|
|
174
179
|
|
package/dist/cli.js
CHANGED
|
@@ -18,21 +18,37 @@ function parsePortOption(port) {
|
|
|
18
18
|
const value = typeof port === "number" ? port : parseInt(String(port || "3000"));
|
|
19
19
|
return Number.isInteger(value) && value > 0 ? value : 3000;
|
|
20
20
|
}
|
|
21
|
+
function clampPositiveInt(value, fallback, max) {
|
|
22
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
23
|
+
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
24
|
+
return fallback;
|
|
25
|
+
return Math.min(parsed, max);
|
|
26
|
+
}
|
|
27
|
+
function printSoftwareAgentTaskList(tasks) {
|
|
28
|
+
if (!tasks.length) {
|
|
29
|
+
console.log("No software-agent tasks found.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
for (const task of tasks) {
|
|
33
|
+
const result = task.result?.success === true ? "ok" : task.result?.success === false ? "error" : "pending";
|
|
34
|
+
const duration = typeof task.durationMs === "number" ? `${task.durationMs}ms` : "-";
|
|
35
|
+
const git = task.workdirStatus?.isRepo
|
|
36
|
+
? (task.workdirStatus.dirty ? `dirty:${task.workdirStatus.changedFiles?.length || 0}` : "clean")
|
|
37
|
+
: "no-git";
|
|
38
|
+
const goal = truncateOneLine(task.envelope?.goal || "", 90);
|
|
39
|
+
console.log(`${task.taskId} ${task.status}/${result} ${duration} ${git} ${task.updatedAt || task.startedAt}`);
|
|
40
|
+
if (goal)
|
|
41
|
+
console.log(` ${goal}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function truncateOneLine(value, max) {
|
|
45
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
46
|
+
if (oneLine.length <= max)
|
|
47
|
+
return oneLine;
|
|
48
|
+
return `${oneLine.slice(0, Math.max(0, max - 3))}...`;
|
|
49
|
+
}
|
|
21
50
|
async function callLocalOwnerEndpoint(path, opts, init = {}) {
|
|
22
|
-
const
|
|
23
|
-
const port = parsePortOption(opts.port);
|
|
24
|
-
const headers = {
|
|
25
|
-
Authorization: `Bearer ${credentials.secretKey}`,
|
|
26
|
-
};
|
|
27
|
-
if (init.body !== undefined)
|
|
28
|
-
headers["Content-Type"] = "application/json";
|
|
29
|
-
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
30
|
-
...init,
|
|
31
|
-
headers: {
|
|
32
|
-
...headers,
|
|
33
|
-
...init.headers,
|
|
34
|
-
},
|
|
35
|
-
});
|
|
51
|
+
const res = await fetchLocalOwnerEndpoint(path, opts, init);
|
|
36
52
|
const text = await res.text();
|
|
37
53
|
let data;
|
|
38
54
|
try {
|
|
@@ -47,6 +63,117 @@ async function callLocalOwnerEndpoint(path, opts, init = {}) {
|
|
|
47
63
|
}
|
|
48
64
|
return data;
|
|
49
65
|
}
|
|
66
|
+
async function fetchLocalOwnerEndpoint(path, opts, init = {}) {
|
|
67
|
+
const credentials = await getOrCreateRelayCredentials();
|
|
68
|
+
const port = parsePortOption(opts.port);
|
|
69
|
+
const headers = {
|
|
70
|
+
Authorization: `Bearer ${credentials.secretKey}`,
|
|
71
|
+
};
|
|
72
|
+
if (init.body !== undefined)
|
|
73
|
+
headers["Content-Type"] = "application/json";
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
77
|
+
...init,
|
|
78
|
+
headers: {
|
|
79
|
+
...headers,
|
|
80
|
+
...init.headers,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const cause = error.cause;
|
|
86
|
+
if (error instanceof TypeError && error.message === "fetch failed" && cause?.message === "bad port") {
|
|
87
|
+
console.error(`Port ${port} cannot be used for the local akemon serve connection. Choose a different --port.`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (error instanceof TypeError && error.message === "fetch failed") {
|
|
91
|
+
console.error(`Cannot connect to local akemon serve on port ${port}. Start it with: akemon serve --port ${port}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
return res;
|
|
97
|
+
}
|
|
98
|
+
async function streamLocalOwnerEndpoint(path, opts, body) {
|
|
99
|
+
const res = await fetchLocalOwnerEndpoint(path, opts, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
let data;
|
|
106
|
+
try {
|
|
107
|
+
data = text ? JSON.parse(text) : {};
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
data = { error: text };
|
|
111
|
+
}
|
|
112
|
+
console.error(data.error || text || `Request failed with HTTP ${res.status}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
if (!res.body)
|
|
116
|
+
return;
|
|
117
|
+
const decoder = new TextDecoder();
|
|
118
|
+
let buffer = "";
|
|
119
|
+
let failed = false;
|
|
120
|
+
const reader = res.body.getReader();
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done)
|
|
124
|
+
break;
|
|
125
|
+
buffer += decoder.decode(value, { stream: true });
|
|
126
|
+
const lines = buffer.split(/\r?\n/);
|
|
127
|
+
buffer = lines.pop() || "";
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (handleSoftwareAgentStreamLine(line))
|
|
130
|
+
failed = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
buffer += decoder.decode();
|
|
134
|
+
if (buffer.trim() && handleSoftwareAgentStreamLine(buffer))
|
|
135
|
+
failed = true;
|
|
136
|
+
if (failed)
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
function handleSoftwareAgentStreamLine(line) {
|
|
140
|
+
const trimmed = line.trim();
|
|
141
|
+
if (!trimmed)
|
|
142
|
+
return false;
|
|
143
|
+
let event;
|
|
144
|
+
try {
|
|
145
|
+
event = JSON.parse(trimmed);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
process.stderr.write(`${trimmed}\n`);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (event.type === "start" && event.taskId) {
|
|
152
|
+
process.stderr.write(`[software-agent] started ${event.taskId}\n`);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (event.type === "stdout" && typeof event.chunk === "string") {
|
|
156
|
+
process.stdout.write(event.chunk);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (event.type === "stderr" && typeof event.chunk === "string") {
|
|
160
|
+
process.stderr.write(event.chunk);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (event.type === "end") {
|
|
164
|
+
const result = event.result;
|
|
165
|
+
if (result?.success === false && result.error) {
|
|
166
|
+
process.stderr.write(`${result.error}\n`);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (event.type === "error") {
|
|
172
|
+
process.stderr.write(`${event.error || "Software-agent stream failed"}\n`);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
50
177
|
program
|
|
51
178
|
.name("akemon")
|
|
52
179
|
.description("Agent work marketplace — train your agent, let it work for others")
|
|
@@ -171,12 +298,14 @@ program
|
|
|
171
298
|
.argument("<goal...>", "Task goal to send to the software agent")
|
|
172
299
|
.option("-p, --port <port>", "Local akemon serve port", "3000")
|
|
173
300
|
.option("-w, --workdir <path>", "Workdir for the software agent (default: serve workdir)")
|
|
301
|
+
.option("--allow-outside-workdir", "Allow the software agent workdir to be outside the serve workdir")
|
|
174
302
|
.option("--role-scope <scope>", "Role scope: owner|public|order|agent|system", "owner")
|
|
175
303
|
.option("--memory-scope <scope>", "Memory scope: none|public|task|owner", "owner")
|
|
176
304
|
.option("--risk <level>", "Risk level: low|medium|high", "medium")
|
|
177
305
|
.option("--memory-summary <text>", "Pre-filtered memory/context text to include")
|
|
178
306
|
.option("--deliverable <text>", "Expected output shape")
|
|
179
307
|
.option("--timeout-ms <ms>", "Task timeout in milliseconds")
|
|
308
|
+
.option("--no-stream", "Disable local streaming and wait for the final response")
|
|
180
309
|
.action(async (goalParts, opts) => {
|
|
181
310
|
const body = {
|
|
182
311
|
goal: goalParts.join(" "),
|
|
@@ -186,6 +315,8 @@ program
|
|
|
186
315
|
};
|
|
187
316
|
if (opts.workdir)
|
|
188
317
|
body.workdir = opts.workdir;
|
|
318
|
+
if (opts.allowOutsideWorkdir)
|
|
319
|
+
body.allowOutsideWorkdir = true;
|
|
189
320
|
if (opts.memorySummary)
|
|
190
321
|
body.memorySummary = opts.memorySummary;
|
|
191
322
|
if (opts.deliverable)
|
|
@@ -198,6 +329,10 @@ program
|
|
|
198
329
|
}
|
|
199
330
|
body.timeoutMs = timeoutMs;
|
|
200
331
|
}
|
|
332
|
+
if (opts.stream !== false) {
|
|
333
|
+
await streamLocalOwnerEndpoint("/self/software-agent/run-stream", opts, body);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
201
336
|
const data = await callLocalOwnerEndpoint("/self/software-agent/run", opts, {
|
|
202
337
|
method: "POST",
|
|
203
338
|
body: JSON.stringify(body),
|
|
@@ -217,6 +352,26 @@ program
|
|
|
217
352
|
});
|
|
218
353
|
console.log(JSON.stringify(data, null, 2));
|
|
219
354
|
});
|
|
355
|
+
program
|
|
356
|
+
.command("software-agent-tasks")
|
|
357
|
+
.description("List recent owner-only software-agent task ledger records")
|
|
358
|
+
.argument("[taskId]", "Task id to inspect")
|
|
359
|
+
.option("-p, --port <port>", "Local akemon serve port", "3000")
|
|
360
|
+
.option("-l, --limit <n>", "Maximum recent tasks to list", "20")
|
|
361
|
+
.option("--json", "Print raw JSON")
|
|
362
|
+
.action(async (taskId, opts) => {
|
|
363
|
+
const path = taskId
|
|
364
|
+
? `/self/software-agent/tasks/${encodeURIComponent(taskId)}`
|
|
365
|
+
: `/self/software-agent/tasks?limit=${clampPositiveInt(opts.limit, 20, 100)}`;
|
|
366
|
+
const data = await callLocalOwnerEndpoint(path, opts, {
|
|
367
|
+
method: "GET",
|
|
368
|
+
});
|
|
369
|
+
if (opts.json || taskId) {
|
|
370
|
+
console.log(JSON.stringify(taskId ? data.task : data, null, 2));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
printSoftwareAgentTaskList(Array.isArray(data.tasks) ? data.tasks : []);
|
|
374
|
+
});
|
|
220
375
|
program
|
|
221
376
|
.command("software-agent-reset")
|
|
222
377
|
.description("Reset the owner-only local software-agent peripheral session")
|
package/dist/server.js
CHANGED
|
@@ -141,6 +141,12 @@ export async function handleSoftwareAgentRunHttp(req, res, deps) {
|
|
|
141
141
|
let envelope;
|
|
142
142
|
try {
|
|
143
143
|
envelope = createOwnerTaskEnvelope(body, deps.workdir);
|
|
144
|
+
envelope.memorySummary = await buildSoftwareAgentMemorySummary({
|
|
145
|
+
workdir: deps.workdir,
|
|
146
|
+
agentName: deps.agentName,
|
|
147
|
+
envelope,
|
|
148
|
+
request: body,
|
|
149
|
+
});
|
|
144
150
|
}
|
|
145
151
|
catch (err) {
|
|
146
152
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
@@ -158,6 +164,109 @@ export async function handleSoftwareAgentRunHttp(req, res, deps) {
|
|
|
158
164
|
.end(JSON.stringify({ error: err.message || String(err) }));
|
|
159
165
|
}
|
|
160
166
|
}
|
|
167
|
+
export async function handleSoftwareAgentRunStreamHttp(req, res, deps) {
|
|
168
|
+
if (!isOwnerRequest(req, deps.options)) {
|
|
169
|
+
res.writeHead(401, { "Content-Type": "application/json" })
|
|
170
|
+
.end(JSON.stringify({ error: "Owner token required" }));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!deps.softwareAgent) {
|
|
174
|
+
res.writeHead(503, { "Content-Type": "application/json" })
|
|
175
|
+
.end(JSON.stringify({ error: "Software agent peripheral not ready" }));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
let body;
|
|
179
|
+
try {
|
|
180
|
+
body = await readJsonBody(req);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
res.writeHead(400, { "Content-Type": "application/json" })
|
|
184
|
+
.end(JSON.stringify({ error: err.message || "Invalid request body" }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
let envelope;
|
|
188
|
+
try {
|
|
189
|
+
envelope = createOwnerTaskEnvelope(body, deps.workdir);
|
|
190
|
+
envelope.memorySummary = await buildSoftwareAgentMemorySummary({
|
|
191
|
+
workdir: deps.workdir,
|
|
192
|
+
agentName: deps.agentName,
|
|
193
|
+
envelope,
|
|
194
|
+
request: body,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
res.writeHead(400, { "Content-Type": "application/json" })
|
|
199
|
+
.end(JSON.stringify({ error: err.message || "Invalid software-agent envelope" }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const abortController = new AbortController();
|
|
203
|
+
let responseFinished = false;
|
|
204
|
+
let streamStarted = false;
|
|
205
|
+
res.on("close", () => {
|
|
206
|
+
if (!responseFinished)
|
|
207
|
+
abortController.abort();
|
|
208
|
+
});
|
|
209
|
+
const ensureStreamStarted = () => {
|
|
210
|
+
if (streamStarted)
|
|
211
|
+
return;
|
|
212
|
+
streamStarted = true;
|
|
213
|
+
res.writeHead(200, {
|
|
214
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
215
|
+
"Cache-Control": "no-cache",
|
|
216
|
+
"X-Accel-Buffering": "no",
|
|
217
|
+
});
|
|
218
|
+
res.flushHeaders?.();
|
|
219
|
+
};
|
|
220
|
+
try {
|
|
221
|
+
await deps.softwareAgent.sendTask(envelope, {
|
|
222
|
+
signal: abortController.signal,
|
|
223
|
+
observer: {
|
|
224
|
+
onStart(event) {
|
|
225
|
+
ensureStreamStarted();
|
|
226
|
+
writeSoftwareAgentStreamEvent(res, {
|
|
227
|
+
type: "start",
|
|
228
|
+
taskId: event.taskId,
|
|
229
|
+
commandLine: event.commandLine,
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
onStream(event) {
|
|
233
|
+
ensureStreamStarted();
|
|
234
|
+
writeSoftwareAgentStreamEvent(res, {
|
|
235
|
+
type: event.stream,
|
|
236
|
+
taskId: event.taskId,
|
|
237
|
+
chunk: event.chunk,
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
onEnd(event) {
|
|
241
|
+
ensureStreamStarted();
|
|
242
|
+
writeSoftwareAgentStreamEvent(res, {
|
|
243
|
+
type: "end",
|
|
244
|
+
taskId: event.taskId,
|
|
245
|
+
result: event.result,
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
if (!streamStarted) {
|
|
253
|
+
const busy = String(err.message || "").includes("busy");
|
|
254
|
+
res.writeHead(busy ? 409 : 500, { "Content-Type": "application/json" })
|
|
255
|
+
.end(JSON.stringify({ error: err.message || String(err) }));
|
|
256
|
+
responseFinished = true;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
writeSoftwareAgentStreamEvent(res, {
|
|
260
|
+
type: "error",
|
|
261
|
+
error: err.message || String(err),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
responseFinished = true;
|
|
266
|
+
if (streamStarted && !res.writableEnded)
|
|
267
|
+
res.end();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
161
270
|
export async function handleSoftwareAgentStatusHttp(req, res, deps) {
|
|
162
271
|
if (!isOwnerRequest(req, deps.options)) {
|
|
163
272
|
res.writeHead(401, { "Content-Type": "application/json" })
|
|
@@ -172,6 +281,58 @@ export async function handleSoftwareAgentStatusHttp(req, res, deps) {
|
|
|
172
281
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
173
282
|
.end(JSON.stringify(deps.softwareAgent.getState(), null, 2));
|
|
174
283
|
}
|
|
284
|
+
export async function handleSoftwareAgentTasksHttp(req, res, deps) {
|
|
285
|
+
if (!isOwnerRequest(req, deps.options)) {
|
|
286
|
+
res.writeHead(401, { "Content-Type": "application/json" })
|
|
287
|
+
.end(JSON.stringify({ error: "Owner token required" }));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
291
|
+
const basePath = "/self/software-agent/tasks";
|
|
292
|
+
const taskLedgerDir = softwareAgentTaskLedgerDir(deps.workdir, deps.agentName);
|
|
293
|
+
if (url.pathname === basePath) {
|
|
294
|
+
const limit = readPositiveIntQuery(url.searchParams.get("limit"), 20, 100);
|
|
295
|
+
const tasks = listSoftwareAgentTaskRecords(taskLedgerDir, limit);
|
|
296
|
+
res.writeHead(200, { "Content-Type": "application/json" })
|
|
297
|
+
.end(JSON.stringify({ tasks }, null, 2));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (url.pathname.startsWith(`${basePath}/`)) {
|
|
301
|
+
const taskId = decodeURIComponent(url.pathname.slice(basePath.length + 1));
|
|
302
|
+
if (!taskId || taskId.includes("/")) {
|
|
303
|
+
res.writeHead(400, { "Content-Type": "application/json" })
|
|
304
|
+
.end(JSON.stringify({ error: "Invalid software-agent task id" }));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const task = readSoftwareAgentTaskRecord(taskLedgerDir, taskId);
|
|
308
|
+
if (!task) {
|
|
309
|
+
res.writeHead(404, { "Content-Type": "application/json" })
|
|
310
|
+
.end(JSON.stringify({ error: "Software-agent task not found" }));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
res.writeHead(200, { "Content-Type": "application/json" })
|
|
314
|
+
.end(JSON.stringify({ task }, null, 2));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
res.writeHead(404, { "Content-Type": "application/json" })
|
|
318
|
+
.end(JSON.stringify({ error: "Software-agent task endpoint not found" }));
|
|
319
|
+
}
|
|
320
|
+
function softwareAgentTaskLedgerDir(workdir, agentName) {
|
|
321
|
+
return join(workdir, ".akemon", "agents", agentName, "software-agent", "tasks");
|
|
322
|
+
}
|
|
323
|
+
function readPositiveIntQuery(value, fallback, max) {
|
|
324
|
+
if (!value)
|
|
325
|
+
return fallback;
|
|
326
|
+
const parsed = Number(value);
|
|
327
|
+
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
328
|
+
return fallback;
|
|
329
|
+
return Math.min(parsed, max);
|
|
330
|
+
}
|
|
331
|
+
function writeSoftwareAgentStreamEvent(res, event) {
|
|
332
|
+
if (res.destroyed)
|
|
333
|
+
return;
|
|
334
|
+
res.write(`${JSON.stringify(event)}\n`);
|
|
335
|
+
}
|
|
175
336
|
export async function handleSoftwareAgentResetHttp(req, res, deps) {
|
|
176
337
|
if (!isOwnerRequest(req, deps.options)) {
|
|
177
338
|
res.writeHead(401, { "Content-Type": "application/json" })
|
|
@@ -205,7 +366,8 @@ import { LongTermModule } from "./longterm-module.js";
|
|
|
205
366
|
import { ReflectionModule } from "./reflection-module.js";
|
|
206
367
|
import { ScriptModule } from "./script-module.js";
|
|
207
368
|
import { FileEventLog, PersistentEventBus } from "./event-bus.js";
|
|
208
|
-
import { CodexSoftwareAgentPeripheral, createOwnerTaskEnvelope } from "./software-agent-peripheral.js";
|
|
369
|
+
import { CodexSoftwareAgentPeripheral, createOwnerTaskEnvelope, listSoftwareAgentTaskRecords, readSoftwareAgentTaskRecord, } from "./software-agent-peripheral.js";
|
|
370
|
+
import { buildSoftwareAgentMemorySummary } from "./software-agent-memory.js";
|
|
209
371
|
import { SIG, sig } from "./types.js";
|
|
210
372
|
import { loadConversation, listConversations, buildLLMContext } from "./context.js";
|
|
211
373
|
import { createMcpServer, initMcpProxy, createMcpProxyServer } from "./mcp-server.js";
|
|
@@ -288,10 +450,20 @@ export async function serve(options) {
|
|
|
288
450
|
if (!isQuiet)
|
|
289
451
|
console.log(`[http] ${req.method} ${req.url} session=${req.headers["mcp-session-id"] || "none"}`);
|
|
290
452
|
try {
|
|
453
|
+
if (req.url === "/self/software-agent/run-stream" && req.method === "POST") {
|
|
454
|
+
await handleSoftwareAgentRunStreamHttp(req, res, {
|
|
455
|
+
options,
|
|
456
|
+
workdir,
|
|
457
|
+
agentName: options.agentName,
|
|
458
|
+
softwareAgent: codexSoftwareAgent,
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
291
462
|
if (req.url === "/self/software-agent/run" && req.method === "POST") {
|
|
292
463
|
await handleSoftwareAgentRunHttp(req, res, {
|
|
293
464
|
options,
|
|
294
465
|
workdir,
|
|
466
|
+
agentName: options.agentName,
|
|
295
467
|
softwareAgent: codexSoftwareAgent,
|
|
296
468
|
});
|
|
297
469
|
return;
|
|
@@ -303,6 +475,16 @@ export async function serve(options) {
|
|
|
303
475
|
});
|
|
304
476
|
return;
|
|
305
477
|
}
|
|
478
|
+
const requestPath = req.url?.split("?")[0] || "";
|
|
479
|
+
if (req.method === "GET"
|
|
480
|
+
&& (requestPath === "/self/software-agent/tasks" || requestPath.startsWith("/self/software-agent/tasks/"))) {
|
|
481
|
+
await handleSoftwareAgentTasksHttp(req, res, {
|
|
482
|
+
options,
|
|
483
|
+
workdir,
|
|
484
|
+
agentName: options.agentName,
|
|
485
|
+
});
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
306
488
|
if (req.url === "/self/software-agent/reset" && req.method === "POST") {
|
|
307
489
|
await handleSoftwareAgentResetHttp(req, res, {
|
|
308
490
|
options,
|
|
@@ -498,10 +680,13 @@ export async function serve(options) {
|
|
|
498
680
|
workdir,
|
|
499
681
|
model: process.env.AKEMON_CODEX_MODEL,
|
|
500
682
|
sandbox: "workspace-write",
|
|
683
|
+
taskLedgerDir: softwareAgentTaskLedgerDir(workdir, options.agentName),
|
|
501
684
|
});
|
|
502
|
-
await codexSoftwareAgent.start(bus);
|
|
503
685
|
// Peripheral registry — Core routes by capability
|
|
504
686
|
const peripherals = [relay, engineP, codexSoftwareAgent];
|
|
687
|
+
for (const peripheral of peripherals) {
|
|
688
|
+
await peripheral.start(bus);
|
|
689
|
+
}
|
|
505
690
|
// requestCompute: acquire the engine slot (priority-aware), execute with a
|
|
506
691
|
// hard timeout, and release. The slot release and subprocess kill are both
|
|
507
692
|
// driven by the same AbortController so a stuck engine can't hold the lock.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { buildLLMContext, loadConversation } from "./context.js";
|
|
2
|
+
import { buildRoleContext, loadRoles, resolveRoles } from "./role-module.js";
|
|
3
|
+
const DEFAULT_CONTEXT_BUDGET = 6000;
|
|
4
|
+
export async function buildSoftwareAgentMemorySummary(opts) {
|
|
5
|
+
const budget = opts.contextBudget ?? DEFAULT_CONTEXT_BUDGET;
|
|
6
|
+
const parts = [
|
|
7
|
+
"[Akemon memory boundary]",
|
|
8
|
+
`Role scope: ${opts.envelope.roleScope}`,
|
|
9
|
+
`Memory scope: ${opts.envelope.memoryScope}`,
|
|
10
|
+
boundaryDescription(opts.envelope.roleScope, opts.envelope.memoryScope),
|
|
11
|
+
];
|
|
12
|
+
if (opts.envelope.memoryScope === "none") {
|
|
13
|
+
parts.push("No Akemon memory/context is included for this task.");
|
|
14
|
+
return parts.join("\n");
|
|
15
|
+
}
|
|
16
|
+
const request = normalizeRequest(opts.request);
|
|
17
|
+
const roleTrigger = readRequestString(request, "roleTrigger") || triggerForRoleScope(opts.envelope.roleScope);
|
|
18
|
+
const productName = readRequestString(request, "productName");
|
|
19
|
+
const productId = readRequestString(request, "productId");
|
|
20
|
+
const rolePolicy = await resolveRoleMemoryPolicy(opts.workdir, opts.agentName, roleTrigger);
|
|
21
|
+
if (rolePolicy.exclude.length) {
|
|
22
|
+
parts.push(`Active role exclusions: ${rolePolicy.exclude.join(", ")}`);
|
|
23
|
+
}
|
|
24
|
+
const roleContext = await buildRoleContext(opts.workdir, opts.agentName, roleTrigger, productName, productId);
|
|
25
|
+
if (roleContext.trim()) {
|
|
26
|
+
parts.push("");
|
|
27
|
+
parts.push("[Role/product context]");
|
|
28
|
+
parts.push(limitText(roleContext.trim(), Math.floor(budget * 0.55)));
|
|
29
|
+
}
|
|
30
|
+
const taskContext = readRequestString(request, "taskContext");
|
|
31
|
+
if (taskContext) {
|
|
32
|
+
parts.push("");
|
|
33
|
+
parts.push("[Task-provided context]");
|
|
34
|
+
parts.push(limitText(taskContext, Math.floor(budget * 0.25)));
|
|
35
|
+
}
|
|
36
|
+
const conversationId = readRequestString(request, "conversationId");
|
|
37
|
+
if (conversationId && canIncludeConversation(opts.envelope.roleScope, opts.envelope.memoryScope)) {
|
|
38
|
+
const conv = await loadConversation(opts.workdir, opts.agentName, conversationId);
|
|
39
|
+
const { text } = buildLLMContext(conv, Math.floor(budget * 0.3));
|
|
40
|
+
if (text.trim()) {
|
|
41
|
+
parts.push("");
|
|
42
|
+
parts.push("[Conversation context]");
|
|
43
|
+
parts.push(text.trim());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (conversationId) {
|
|
47
|
+
parts.push("");
|
|
48
|
+
parts.push("[Excluded conversation context]");
|
|
49
|
+
parts.push("A conversationId was supplied, but conversation memory is only included for owner-scoped software-agent tasks in v1.");
|
|
50
|
+
}
|
|
51
|
+
const ownerMemory = readRequestString(request, "memorySummary");
|
|
52
|
+
if (ownerMemory && canIncludeOwnerMemory(opts.envelope.roleScope, opts.envelope.memoryScope)) {
|
|
53
|
+
if (roleExcludesOwnerMemory(rolePolicy)) {
|
|
54
|
+
parts.push("");
|
|
55
|
+
parts.push("[Role-excluded owner memory]");
|
|
56
|
+
parts.push(`The active role (${rolePolicy.roleName || "unknown"}) excludes ${rolePolicy.exclude.join(", ")}, so owner-provided memory was not included.`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
parts.push("");
|
|
60
|
+
parts.push("[Owner-visible memory]");
|
|
61
|
+
parts.push(limitText(ownerMemory, Math.floor(budget * 0.35)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (ownerMemory) {
|
|
65
|
+
parts.push("");
|
|
66
|
+
parts.push("[Excluded owner memory]");
|
|
67
|
+
parts.push("A memorySummary was supplied, but it was not included because this envelope is not owner/owner scoped.");
|
|
68
|
+
}
|
|
69
|
+
return limitText(parts.join("\n"), budget);
|
|
70
|
+
}
|
|
71
|
+
export function canIncludeOwnerMemory(roleScope, memoryScope) {
|
|
72
|
+
return roleScope === "owner" && memoryScope === "owner";
|
|
73
|
+
}
|
|
74
|
+
function canIncludeConversation(roleScope, memoryScope) {
|
|
75
|
+
return roleScope === "owner" && (memoryScope === "owner" || memoryScope === "task");
|
|
76
|
+
}
|
|
77
|
+
function triggerForRoleScope(roleScope) {
|
|
78
|
+
switch (roleScope) {
|
|
79
|
+
case "owner": return "trigger:chat:owner";
|
|
80
|
+
case "public": return "trigger:chat:public";
|
|
81
|
+
case "order": return "trigger:order";
|
|
82
|
+
case "agent": return "trigger:agent_call";
|
|
83
|
+
case "system": return "trigger:system";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function boundaryDescription(roleScope, memoryScope) {
|
|
87
|
+
if (roleScope === "owner" && memoryScope === "owner") {
|
|
88
|
+
return "Owner-scoped task: owner-visible memory may be included after Akemon-side selection.";
|
|
89
|
+
}
|
|
90
|
+
if (memoryScope === "none") {
|
|
91
|
+
return "No-memory task: do not use Akemon private memory, conversation history, or subjective state.";
|
|
92
|
+
}
|
|
93
|
+
return "Non-owner task: exclude owner private conversations, personal notes, bio state, diary, subjective impressions, and owner-only memory.";
|
|
94
|
+
}
|
|
95
|
+
async function resolveRoleMemoryPolicy(workdir, agentName, roleTrigger) {
|
|
96
|
+
const roles = await loadRoles(workdir, agentName);
|
|
97
|
+
const { primary } = resolveRoles(roles, roleTrigger);
|
|
98
|
+
return {
|
|
99
|
+
roleName: primary?.name || null,
|
|
100
|
+
exclude: primary?.exclude || [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function roleExcludesOwnerMemory(policy) {
|
|
104
|
+
return policy.exclude.some((item) => {
|
|
105
|
+
const normalized = item.toLowerCase();
|
|
106
|
+
return normalized.includes("owner")
|
|
107
|
+
|| normalized.includes("private")
|
|
108
|
+
|| normalized.includes("personal")
|
|
109
|
+
|| normalized.includes("note")
|
|
110
|
+
|| normalized.includes("diary")
|
|
111
|
+
|| normalized.includes("bio")
|
|
112
|
+
|| normalized.includes("全部记忆")
|
|
113
|
+
|| normalized.includes("个人")
|
|
114
|
+
|| normalized.includes("笔记")
|
|
115
|
+
|| normalized.includes("日记")
|
|
116
|
+
|| normalized.includes("状态");
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function normalizeRequest(value) {
|
|
120
|
+
if (value === undefined || value === null)
|
|
121
|
+
return {};
|
|
122
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
123
|
+
throw new Error("Invalid request: expected object");
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
function readRequestString(request, field) {
|
|
128
|
+
const value = request[field];
|
|
129
|
+
if (value === undefined || value === null)
|
|
130
|
+
return "";
|
|
131
|
+
if (typeof value !== "string")
|
|
132
|
+
throw new Error(`Invalid ${field}: expected string`);
|
|
133
|
+
return value.trim();
|
|
134
|
+
}
|
|
135
|
+
function limitText(text, maxChars) {
|
|
136
|
+
if (text.length <= maxChars)
|
|
137
|
+
return text;
|
|
138
|
+
const head = Math.floor(maxChars * 0.45);
|
|
139
|
+
const tail = Math.max(0, maxChars - head - 40);
|
|
140
|
+
return `${text.slice(0, head)}\n[truncated ${text.length - head - tail} chars]\n${text.slice(-tail)}`;
|
|
141
|
+
}
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* transport to app-server or a true persistent interactive session.
|
|
12
12
|
*/
|
|
13
13
|
import { randomUUID } from "crypto";
|
|
14
|
-
import { spawn } from "child_process";
|
|
14
|
+
import { spawn, spawnSync } from "child_process";
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
16
|
+
import { isAbsolute, join, relative, resolve as resolvePath } from "path";
|
|
15
17
|
import { StringDecoder } from "string_decoder";
|
|
16
18
|
import { SIG, sig } from "./types.js";
|
|
17
19
|
import { sendTaskEnd, sendTaskStart, sendTaskStream } from "./relay-client.js";
|
|
@@ -30,6 +32,9 @@ const DEFAULT_OWNER_FORBIDDEN_ACTIONS = [
|
|
|
30
32
|
const ROLE_SCOPES = ["owner", "public", "order", "agent", "system"];
|
|
31
33
|
const MEMORY_SCOPES = ["none", "public", "task", "owner"];
|
|
32
34
|
const RISK_LEVELS = ["low", "medium", "high"];
|
|
35
|
+
const MAX_STREAM_SUMMARY_CHARS = 12_000;
|
|
36
|
+
const STREAM_SUMMARY_HEAD_CHARS = 4_000;
|
|
37
|
+
const STREAM_SUMMARY_TAIL_CHARS = 8_000;
|
|
33
38
|
export class CodexSoftwareAgentPeripheral {
|
|
34
39
|
id;
|
|
35
40
|
name;
|
|
@@ -39,6 +44,7 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
39
44
|
bus = null;
|
|
40
45
|
activeChild = null;
|
|
41
46
|
activeTaskId = null;
|
|
47
|
+
activeWorkdir = null;
|
|
42
48
|
sessionId = randomUUID();
|
|
43
49
|
constructor(config) {
|
|
44
50
|
this.config = config;
|
|
@@ -73,15 +79,21 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
73
79
|
}
|
|
74
80
|
this.activeChild = null;
|
|
75
81
|
this.activeTaskId = null;
|
|
82
|
+
this.activeWorkdir = null;
|
|
76
83
|
this.sessionId = randomUUID();
|
|
77
84
|
}
|
|
78
85
|
getState() {
|
|
86
|
+
const currentWorkdir = this.activeWorkdir || resolvePath(this.config.workdir);
|
|
79
87
|
return {
|
|
80
88
|
id: this.id,
|
|
81
89
|
sessionId: this.sessionId,
|
|
82
90
|
activeTaskId: this.activeTaskId,
|
|
91
|
+
activeWorkdir: this.activeWorkdir,
|
|
83
92
|
busy: !!this.activeChild,
|
|
84
93
|
transport: "codex-exec",
|
|
94
|
+
baseWorkdir: resolvePath(this.config.workdir),
|
|
95
|
+
workdirStatus: this.collectWorkdirStatus(currentWorkdir),
|
|
96
|
+
taskLedgerDir: this.config.taskLedgerDir,
|
|
85
97
|
};
|
|
86
98
|
}
|
|
87
99
|
async send(signal) {
|
|
@@ -97,13 +109,16 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
97
109
|
const result = await this.sendTask(envelope);
|
|
98
110
|
return sig(SIG.SOFTWARE_AGENT_RESPONSE, { ...result }, this.id);
|
|
99
111
|
}
|
|
100
|
-
async sendTask(envelope,
|
|
112
|
+
async sendTask(envelope, taskOptions) {
|
|
101
113
|
if (this.activeChild) {
|
|
102
114
|
throw new Error(`Software agent busy (task=${this.activeTaskId})`);
|
|
103
115
|
}
|
|
116
|
+
const { signal, observer } = normalizeSoftwareAgentTaskOptions(taskOptions);
|
|
104
117
|
const taskId = envelope.taskId || `sw_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
105
|
-
const
|
|
106
|
-
const
|
|
118
|
+
const workdirSafety = resolveWorkdirSafety(this.config.workdir, envelope.workdir || this.config.workdir, envelope.workdirSafety?.allowOutsideWorkdir || false);
|
|
119
|
+
const workdir = workdirSafety.effectiveWorkdir;
|
|
120
|
+
const effectiveEnvelope = { ...envelope, taskId, workdir, workdirSafety };
|
|
121
|
+
const prompt = buildTaskEnvelopePrompt(effectiveEnvelope);
|
|
107
122
|
const { cmd, args } = buildCodexExecCommand({
|
|
108
123
|
command: this.config.command || "codex",
|
|
109
124
|
workdir,
|
|
@@ -111,17 +126,38 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
111
126
|
sandbox: this.config.sandbox || "workspace-write",
|
|
112
127
|
});
|
|
113
128
|
const startedAt = Date.now();
|
|
129
|
+
const startedAtIso = new Date(startedAt).toISOString();
|
|
114
130
|
const relay = this.config.taskRelay || defaultTaskRelay;
|
|
115
131
|
const commandLine = [cmd, ...args].join(" ");
|
|
116
132
|
const spawnImpl = this.config.spawnImpl || spawn;
|
|
117
133
|
const timeoutMs = envelope.timeoutMs || this.config.defaultTimeoutMs || DEFAULT_TIMEOUT_MS;
|
|
134
|
+
const workdirStatus = this.collectWorkdirStatus(workdir);
|
|
135
|
+
const baseTaskRecord = () => ({
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
taskId,
|
|
138
|
+
agentId: this.id,
|
|
139
|
+
sessionId: this.sessionId,
|
|
140
|
+
transport: "codex-exec",
|
|
141
|
+
commandLine,
|
|
142
|
+
envelope: effectiveEnvelope,
|
|
143
|
+
startedAt: startedAtIso,
|
|
144
|
+
workdirStatus,
|
|
145
|
+
});
|
|
118
146
|
return new Promise((resolve) => {
|
|
119
147
|
this.activeTaskId = taskId;
|
|
120
|
-
|
|
148
|
+
this.activeWorkdir = workdir;
|
|
149
|
+
this.writeTaskRecord({
|
|
150
|
+
...baseTaskRecord(),
|
|
151
|
+
status: "running",
|
|
152
|
+
updatedAt: startedAtIso,
|
|
153
|
+
});
|
|
154
|
+
const origin = "software_agent";
|
|
155
|
+
relay.sendTaskStart(taskId, origin, commandLine);
|
|
156
|
+
observer?.onStart?.({ taskId, origin, commandLine });
|
|
121
157
|
this.bus?.emit(SIG.TASK_STARTED, sig(SIG.TASK_STARTED, {
|
|
122
158
|
taskId,
|
|
123
159
|
taskType: "software_agent",
|
|
124
|
-
description:
|
|
160
|
+
description: effectiveEnvelope.goal,
|
|
125
161
|
peripheral: this.id,
|
|
126
162
|
sessionId: this.sessionId,
|
|
127
163
|
}, this.id));
|
|
@@ -137,8 +173,8 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
137
173
|
catch (err) {
|
|
138
174
|
this.activeChild = null;
|
|
139
175
|
this.activeTaskId = null;
|
|
176
|
+
this.activeWorkdir = null;
|
|
140
177
|
const durationMs = Date.now() - startedAt;
|
|
141
|
-
relay.sendTaskEnd(taskId, null, durationMs);
|
|
142
178
|
const result = {
|
|
143
179
|
success: false,
|
|
144
180
|
taskId,
|
|
@@ -147,6 +183,19 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
147
183
|
exitCode: null,
|
|
148
184
|
durationMs,
|
|
149
185
|
};
|
|
186
|
+
relay.sendTaskEnd(taskId, null, durationMs);
|
|
187
|
+
observer?.onEnd?.({ taskId, exitCode: null, durationMs, result });
|
|
188
|
+
const completedAt = new Date().toISOString();
|
|
189
|
+
this.writeTaskRecord({
|
|
190
|
+
...baseTaskRecord(),
|
|
191
|
+
status: "failed",
|
|
192
|
+
updatedAt: completedAt,
|
|
193
|
+
completedAt,
|
|
194
|
+
durationMs,
|
|
195
|
+
result,
|
|
196
|
+
stdoutSummary: summarizeText(""),
|
|
197
|
+
stderrSummary: summarizeText(result.error || ""),
|
|
198
|
+
});
|
|
150
199
|
this.bus?.emit(SIG.TASK_FAILED, sig(SIG.TASK_FAILED, result, this.id));
|
|
151
200
|
resolve(result);
|
|
152
201
|
return;
|
|
@@ -169,15 +218,17 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
169
218
|
if (tailOut) {
|
|
170
219
|
stdout += tailOut;
|
|
171
220
|
relay.sendTaskStream(taskId, "stdout", tailOut);
|
|
221
|
+
observer?.onStream?.({ taskId, stream: "stdout", chunk: tailOut });
|
|
172
222
|
}
|
|
173
223
|
if (tailErr) {
|
|
174
224
|
stderr += tailErr;
|
|
175
225
|
relay.sendTaskStream(taskId, "stderr", tailErr);
|
|
226
|
+
observer?.onStream?.({ taskId, stream: "stderr", chunk: tailErr });
|
|
176
227
|
}
|
|
177
228
|
const durationMs = Date.now() - startedAt;
|
|
178
|
-
relay.sendTaskEnd(taskId, exitCode, durationMs);
|
|
179
229
|
this.activeChild = null;
|
|
180
230
|
this.activeTaskId = null;
|
|
231
|
+
this.activeWorkdir = null;
|
|
181
232
|
const output = stdout.trim() || stderr.trim();
|
|
182
233
|
const success = !error && !aborted && exitCode === 0;
|
|
183
234
|
const result = {
|
|
@@ -188,6 +239,19 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
188
239
|
exitCode,
|
|
189
240
|
durationMs,
|
|
190
241
|
};
|
|
242
|
+
relay.sendTaskEnd(taskId, exitCode, durationMs);
|
|
243
|
+
observer?.onEnd?.({ taskId, exitCode, durationMs, result });
|
|
244
|
+
const completedAt = new Date().toISOString();
|
|
245
|
+
this.writeTaskRecord({
|
|
246
|
+
...baseTaskRecord(),
|
|
247
|
+
status: success ? "completed" : "failed",
|
|
248
|
+
updatedAt: completedAt,
|
|
249
|
+
completedAt,
|
|
250
|
+
durationMs,
|
|
251
|
+
result,
|
|
252
|
+
stdoutSummary: summarizeText(stdout),
|
|
253
|
+
stderrSummary: summarizeText(stderr),
|
|
254
|
+
});
|
|
191
255
|
this.bus?.emit(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, sig(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, {
|
|
192
256
|
...result,
|
|
193
257
|
taskLabel: `software_agent:${this.id}`,
|
|
@@ -239,6 +303,7 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
239
303
|
return;
|
|
240
304
|
stdout += text;
|
|
241
305
|
relay.sendTaskStream(taskId, "stdout", text);
|
|
306
|
+
observer?.onStream?.({ taskId, stream: "stdout", chunk: text });
|
|
242
307
|
});
|
|
243
308
|
child.stderr?.on("data", (chunk) => {
|
|
244
309
|
const text = errDecoder.write(chunk);
|
|
@@ -246,6 +311,7 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
246
311
|
return;
|
|
247
312
|
stderr += text;
|
|
248
313
|
relay.sendTaskStream(taskId, "stderr", text);
|
|
314
|
+
observer?.onStream?.({ taskId, stream: "stderr", chunk: text });
|
|
249
315
|
});
|
|
250
316
|
child.on("close", (code) => {
|
|
251
317
|
child.unref();
|
|
@@ -257,6 +323,48 @@ export class CodexSoftwareAgentPeripheral {
|
|
|
257
323
|
});
|
|
258
324
|
});
|
|
259
325
|
}
|
|
326
|
+
writeTaskRecord(record) {
|
|
327
|
+
const dir = this.config.taskLedgerDir;
|
|
328
|
+
if (!dir)
|
|
329
|
+
return;
|
|
330
|
+
try {
|
|
331
|
+
mkdirSync(dir, { recursive: true });
|
|
332
|
+
const safeTaskId = safeTaskFilename(record.taskId);
|
|
333
|
+
writeFileSync(join(dir, `${safeTaskId}.json`), `${JSON.stringify(record, null, 2)}\n`);
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
console.error(`[software-agent] Failed to write task ledger: ${err.message || String(err)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
collectWorkdirStatus(workdir) {
|
|
340
|
+
const impl = this.config.gitStatusImpl || readGitWorktreeStatus;
|
|
341
|
+
return impl(workdir);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
export function summarizeText(text, maxChars = MAX_STREAM_SUMMARY_CHARS) {
|
|
345
|
+
const normalized = text || "";
|
|
346
|
+
const chars = normalized.length;
|
|
347
|
+
const bytes = Buffer.byteLength(normalized, "utf8");
|
|
348
|
+
const lines = normalized ? normalized.split(/\r\n|\r|\n/).length : 0;
|
|
349
|
+
if (chars <= maxChars) {
|
|
350
|
+
return { chars, bytes, lines, text: normalized, truncated: false };
|
|
351
|
+
}
|
|
352
|
+
const headChars = Math.min(STREAM_SUMMARY_HEAD_CHARS, maxChars);
|
|
353
|
+
const tailChars = Math.max(0, maxChars - headChars);
|
|
354
|
+
const omittedChars = chars - headChars - tailChars;
|
|
355
|
+
const tailText = tailChars > 0 ? normalized.slice(-tailChars) : "";
|
|
356
|
+
return {
|
|
357
|
+
chars,
|
|
358
|
+
bytes,
|
|
359
|
+
lines,
|
|
360
|
+
text: [
|
|
361
|
+
normalized.slice(0, headChars),
|
|
362
|
+
`[truncated ${omittedChars} chars]`,
|
|
363
|
+
tailText,
|
|
364
|
+
].join("\n"),
|
|
365
|
+
truncated: true,
|
|
366
|
+
omittedChars,
|
|
367
|
+
};
|
|
260
368
|
}
|
|
261
369
|
export function buildTaskEnvelopePrompt(envelope) {
|
|
262
370
|
const lines = [
|
|
@@ -269,11 +377,15 @@ export function buildTaskEnvelopePrompt(envelope) {
|
|
|
269
377
|
`Memory scope: ${envelope.memoryScope}`,
|
|
270
378
|
`Risk level: ${envelope.riskLevel}`,
|
|
271
379
|
`Workdir: ${envelope.workdir}`,
|
|
272
|
-
"",
|
|
273
|
-
"Goal:",
|
|
274
|
-
envelope.goal,
|
|
275
|
-
"",
|
|
276
380
|
];
|
|
381
|
+
if (envelope.workdirSafety) {
|
|
382
|
+
lines.push(`Base workdir: ${envelope.workdirSafety.baseWorkdir}`);
|
|
383
|
+
lines.push(`Requested workdir: ${envelope.workdirSafety.requestedWorkdir}`);
|
|
384
|
+
lines.push(`Effective workdir: ${envelope.workdirSafety.effectiveWorkdir}`);
|
|
385
|
+
lines.push(`Outside base workdir: ${envelope.workdirSafety.outsideBaseWorkdir ? "yes" : "no"}`);
|
|
386
|
+
lines.push(`Outside workdir explicitly allowed: ${envelope.workdirSafety.allowOutsideWorkdir ? "yes" : "no"}`);
|
|
387
|
+
}
|
|
388
|
+
lines.push("", "Goal:", envelope.goal, "");
|
|
277
389
|
if (envelope.memorySummary?.trim()) {
|
|
278
390
|
lines.push("Visible Akemon memory/context:");
|
|
279
391
|
lines.push(envelope.memorySummary.trim());
|
|
@@ -307,27 +419,123 @@ export function createOwnerTaskEnvelope(body, defaultWorkdir) {
|
|
|
307
419
|
const goal = typeof body?.goal === "string" ? body.goal.trim() : "";
|
|
308
420
|
if (!goal)
|
|
309
421
|
throw new Error("Missing required string field: goal");
|
|
310
|
-
const callerForbiddenActions = readOptionalStringArray(body
|
|
422
|
+
const callerForbiddenActions = readOptionalStringArray(body?.forbiddenActions, "forbiddenActions");
|
|
423
|
+
const requestedWorkdir = readOptionalString(body?.workdir, "workdir") || defaultWorkdir;
|
|
424
|
+
const workdirSafety = resolveWorkdirSafety(defaultWorkdir, requestedWorkdir, readOptionalBoolean(body?.allowOutsideWorkdir, "allowOutsideWorkdir") || false);
|
|
311
425
|
return {
|
|
312
|
-
taskId: readOptionalString(body
|
|
426
|
+
taskId: readOptionalString(body?.taskId, "taskId"),
|
|
313
427
|
sourceModule: "owner-http",
|
|
314
|
-
purpose: readOptionalString(body
|
|
428
|
+
purpose: readOptionalString(body?.purpose, "purpose") || "owner software-agent task",
|
|
315
429
|
goal,
|
|
316
|
-
workdir:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
430
|
+
workdir: workdirSafety.effectiveWorkdir,
|
|
431
|
+
workdirSafety,
|
|
432
|
+
roleScope: readEnum(body?.roleScope, "roleScope", ROLE_SCOPES, "owner"),
|
|
433
|
+
memoryScope: readEnum(body?.memoryScope, "memoryScope", MEMORY_SCOPES, "owner"),
|
|
434
|
+
riskLevel: readEnum(body?.riskLevel, "riskLevel", RISK_LEVELS, "medium"),
|
|
435
|
+
allowedActions: body?.allowedActions !== undefined
|
|
436
|
+
? readOptionalStringArray(body?.allowedActions, "allowedActions")
|
|
322
437
|
: [...DEFAULT_OWNER_ALLOWED_ACTIONS],
|
|
323
438
|
forbiddenActions: [...new Set([...DEFAULT_OWNER_FORBIDDEN_ACTIONS, ...callerForbiddenActions])],
|
|
324
|
-
memorySummary: typeof body
|
|
325
|
-
deliverable: typeof body
|
|
439
|
+
memorySummary: typeof body?.memorySummary === "string" ? body.memorySummary : "",
|
|
440
|
+
deliverable: typeof body?.deliverable === "string"
|
|
326
441
|
? body.deliverable
|
|
327
442
|
: "Return a concise engineering summary with changes, verification, and remaining risks.",
|
|
328
|
-
timeoutMs: readTimeoutMs(body
|
|
443
|
+
timeoutMs: readTimeoutMs(body?.timeoutMs),
|
|
329
444
|
};
|
|
330
445
|
}
|
|
446
|
+
export function resolveWorkdirSafety(baseWorkdir, requestedWorkdir, allowOutsideWorkdir = false) {
|
|
447
|
+
const base = resolvePath(baseWorkdir);
|
|
448
|
+
const requested = isAbsolute(requestedWorkdir)
|
|
449
|
+
? resolvePath(requestedWorkdir)
|
|
450
|
+
: resolvePath(base, requestedWorkdir);
|
|
451
|
+
const rel = relative(base, requested);
|
|
452
|
+
const outsideBaseWorkdir = !!rel && (rel.startsWith("..") || isAbsolute(rel));
|
|
453
|
+
if (outsideBaseWorkdir && !allowOutsideWorkdir) {
|
|
454
|
+
throw new Error(`Invalid workdir: ${requested} is outside base workdir ${base}`);
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
baseWorkdir: base,
|
|
458
|
+
requestedWorkdir,
|
|
459
|
+
effectiveWorkdir: requested,
|
|
460
|
+
allowOutsideWorkdir,
|
|
461
|
+
outsideBaseWorkdir,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
export function readGitWorktreeStatus(workdir) {
|
|
465
|
+
const resolvedWorkdir = resolvePath(workdir);
|
|
466
|
+
try {
|
|
467
|
+
const rootResult = spawnSync("git", ["-C", resolvedWorkdir, "rev-parse", "--show-toplevel"], {
|
|
468
|
+
encoding: "utf8",
|
|
469
|
+
timeout: 5000,
|
|
470
|
+
});
|
|
471
|
+
if (rootResult.status !== 0) {
|
|
472
|
+
return {
|
|
473
|
+
workdir: resolvedWorkdir,
|
|
474
|
+
isRepo: false,
|
|
475
|
+
dirty: false,
|
|
476
|
+
changedFiles: [],
|
|
477
|
+
error: summarizeGitError(rootResult.stderr, rootResult.error),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const root = String(rootResult.stdout || "").trim();
|
|
481
|
+
const statusResult = spawnSync("git", ["-C", resolvedWorkdir, "status", "--short"], {
|
|
482
|
+
encoding: "utf8",
|
|
483
|
+
timeout: 5000,
|
|
484
|
+
});
|
|
485
|
+
if (statusResult.status !== 0) {
|
|
486
|
+
return {
|
|
487
|
+
workdir: resolvedWorkdir,
|
|
488
|
+
isRepo: true,
|
|
489
|
+
dirty: false,
|
|
490
|
+
changedFiles: [],
|
|
491
|
+
root,
|
|
492
|
+
error: summarizeGitError(statusResult.stderr, statusResult.error),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const changedFiles = String(statusResult.stdout || "")
|
|
496
|
+
.split(/\r?\n/)
|
|
497
|
+
.map((line) => line.trimEnd())
|
|
498
|
+
.filter(Boolean)
|
|
499
|
+
.map((line) => line.slice(3).trim())
|
|
500
|
+
.filter(Boolean);
|
|
501
|
+
return {
|
|
502
|
+
workdir: resolvedWorkdir,
|
|
503
|
+
isRepo: true,
|
|
504
|
+
dirty: changedFiles.length > 0,
|
|
505
|
+
changedFiles,
|
|
506
|
+
root,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
return {
|
|
511
|
+
workdir: resolvedWorkdir,
|
|
512
|
+
isRepo: false,
|
|
513
|
+
dirty: false,
|
|
514
|
+
changedFiles: [],
|
|
515
|
+
error: err.message || String(err),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
export function listSoftwareAgentTaskRecords(taskLedgerDir, limit = 20) {
|
|
520
|
+
const safeLimit = normalizeTaskRecordLimit(limit);
|
|
521
|
+
try {
|
|
522
|
+
if (!existsSync(taskLedgerDir))
|
|
523
|
+
return [];
|
|
524
|
+
return readdirSync(taskLedgerDir, { withFileTypes: true })
|
|
525
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
526
|
+
.map((entry) => readSoftwareAgentTaskRecordFile(join(taskLedgerDir, entry.name)))
|
|
527
|
+
.filter((record) => !!record)
|
|
528
|
+
.sort(compareSoftwareAgentTaskRecords)
|
|
529
|
+
.slice(0, safeLimit);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
export function readSoftwareAgentTaskRecord(taskLedgerDir, taskId) {
|
|
536
|
+
const file = join(taskLedgerDir, `${safeTaskFilename(taskId)}.json`);
|
|
537
|
+
return readSoftwareAgentTaskRecordFile(file);
|
|
538
|
+
}
|
|
331
539
|
function readOptionalString(value, field) {
|
|
332
540
|
if (value === undefined || value === null)
|
|
333
541
|
return undefined;
|
|
@@ -336,6 +544,19 @@ function readOptionalString(value, field) {
|
|
|
336
544
|
const trimmed = value.trim();
|
|
337
545
|
return trimmed || undefined;
|
|
338
546
|
}
|
|
547
|
+
function normalizeSoftwareAgentTaskOptions(options) {
|
|
548
|
+
if (!options)
|
|
549
|
+
return {};
|
|
550
|
+
if (isAbortSignal(options)) {
|
|
551
|
+
return { signal: options };
|
|
552
|
+
}
|
|
553
|
+
return options;
|
|
554
|
+
}
|
|
555
|
+
function isAbortSignal(value) {
|
|
556
|
+
return !!value
|
|
557
|
+
&& typeof value.aborted === "boolean"
|
|
558
|
+
&& typeof value.addEventListener === "function";
|
|
559
|
+
}
|
|
339
560
|
function readOptionalStringArray(value, field) {
|
|
340
561
|
if (value === undefined || value === null)
|
|
341
562
|
return [];
|
|
@@ -348,6 +569,13 @@ function readOptionalStringArray(value, field) {
|
|
|
348
569
|
return item.trim();
|
|
349
570
|
});
|
|
350
571
|
}
|
|
572
|
+
function readOptionalBoolean(value, field) {
|
|
573
|
+
if (value === undefined || value === null)
|
|
574
|
+
return undefined;
|
|
575
|
+
if (typeof value !== "boolean")
|
|
576
|
+
throw new Error(`Invalid ${field}: expected boolean`);
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
351
579
|
function readEnum(value, field, allowed, fallback) {
|
|
352
580
|
if (value === undefined || value === null || value === "")
|
|
353
581
|
return fallback;
|
|
@@ -367,6 +595,51 @@ function readTimeoutMs(value) {
|
|
|
367
595
|
}
|
|
368
596
|
return value;
|
|
369
597
|
}
|
|
598
|
+
function safeTaskFilename(taskId) {
|
|
599
|
+
const safe = taskId.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 200);
|
|
600
|
+
return safe || "task";
|
|
601
|
+
}
|
|
602
|
+
function summarizeGitError(stderr, error) {
|
|
603
|
+
if (error)
|
|
604
|
+
return error.message;
|
|
605
|
+
const text = typeof stderr === "string" ? stderr.trim() : "";
|
|
606
|
+
return text || undefined;
|
|
607
|
+
}
|
|
608
|
+
function normalizeTaskRecordLimit(limit) {
|
|
609
|
+
if (!Number.isInteger(limit) || limit <= 0)
|
|
610
|
+
return 20;
|
|
611
|
+
return Math.min(limit, 100);
|
|
612
|
+
}
|
|
613
|
+
function readSoftwareAgentTaskRecordFile(file) {
|
|
614
|
+
try {
|
|
615
|
+
if (!existsSync(file))
|
|
616
|
+
return null;
|
|
617
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
618
|
+
if (!isSoftwareAgentTaskRecord(parsed))
|
|
619
|
+
return null;
|
|
620
|
+
return parsed;
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function isSoftwareAgentTaskRecord(value) {
|
|
627
|
+
return value
|
|
628
|
+
&& value.schemaVersion === 1
|
|
629
|
+
&& typeof value.taskId === "string"
|
|
630
|
+
&& (value.status === "running" || value.status === "completed" || value.status === "failed")
|
|
631
|
+
&& typeof value.startedAt === "string"
|
|
632
|
+
&& typeof value.updatedAt === "string"
|
|
633
|
+
&& value.envelope
|
|
634
|
+
&& typeof value.envelope.goal === "string";
|
|
635
|
+
}
|
|
636
|
+
function compareSoftwareAgentTaskRecords(a, b) {
|
|
637
|
+
const bTime = Date.parse(b.updatedAt || b.startedAt) || 0;
|
|
638
|
+
const aTime = Date.parse(a.updatedAt || a.startedAt) || 0;
|
|
639
|
+
if (bTime !== aTime)
|
|
640
|
+
return bTime - aTime;
|
|
641
|
+
return b.taskId.localeCompare(a.taskId);
|
|
642
|
+
}
|
|
370
643
|
function buildCodexExecCommand(opts) {
|
|
371
644
|
const args = [
|
|
372
645
|
"exec",
|