@sema-agent/core 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/observer.js +8 -3
- package/dist/agents/send-message-tool.js +112 -81
- package/dist/agents/subagent.d.ts +10 -4
- package/dist/agents/subagent.js +103 -53
- package/dist/core/memory-engine/dual-root.js +2 -0
- package/dist/core/memory-engine/engine.d.ts +4 -0
- package/dist/core/memory-engine/engine.js +6 -1
- package/dist/core/runner/prepare-memory.d.ts +4 -0
- package/dist/core/runner/prepare-memory.js +4 -1
- package/dist/core/runner/prepare-task.d.ts +8 -2
- package/dist/core/runner/prepare-task.js +39 -8
- package/dist/core/runner/runtask.js +910 -865
- package/dist/core/runner/turn-attachments.d.ts +15 -1
- package/dist/core/runner/turn-attachments.js +68 -9
- package/dist/core/tool-result-budget.js +2 -2
- package/dist/core/tool-result-store.d.ts +2 -0
- package/dist/core/tool-result-store.js +27 -2
- package/dist/core/types.d.ts +1 -1
- package/dist/core/workflow-journal-store.d.ts +13 -0
- package/dist/engine/session/import-validate.js +29 -0
- package/dist/orchestration/workflow.js +28 -1
- package/dist/prompt-assembly/event-registry.js +1 -1
- package/dist/stores/cc/task-list-store.js +3 -3
- package/dist/stores/file/memory-store.d.ts +3 -0
- package/dist/stores/file/memory-store.js +39 -12
- package/dist/stores/file/tool-result-store.js +16 -2
- package/dist/stores/file/workflow-journal-store.d.ts +17 -0
- package/dist/stores/file/workflow-journal-store.js +102 -2
- package/dist/tools/fs/bash-readonly-classifier.js +1 -1
- package/dist/tools/fs/fs-read.js +2 -2
- package/dist/tools/fs/safety.js +13 -6
- package/dist/tools/task-list.d.ts +1 -0
- package/dist/tools/task-list.js +13 -2
- package/dist/tools/web.js +36 -6
- package/package.json +1 -1
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { callKeyOrdinal } from "../../core/workflow-journal-store.js";
|
|
4
5
|
import { AppendLog } from "./fs-atomic.js";
|
|
5
6
|
import { canonicalStoreKey, sanitizePathComponent } from "./fs-atomic.js";
|
|
6
7
|
import { oversizeJournalResult } from "../../core/workflow-journal-store.js";
|
|
7
8
|
export { MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "../../core/workflow-journal-store.js";
|
|
9
|
+
export const RESUME_CLAIM_TTL_MS = 60 * 60 * 1000;
|
|
8
10
|
const sharedJournalDirs = new Map();
|
|
11
|
+
const MAX_OPEN_JOURNAL_LOGS = 64;
|
|
9
12
|
export class FileWorkflowJournalStore {
|
|
10
13
|
fsyncEnabled;
|
|
11
14
|
dir;
|
|
15
|
+
claimsDir;
|
|
12
16
|
shared;
|
|
13
17
|
sharedKey;
|
|
14
18
|
closed = false;
|
|
@@ -22,6 +26,7 @@ export class FileWorkflowJournalStore {
|
|
|
22
26
|
this.fsyncEnabled = fsyncEnabled;
|
|
23
27
|
this.dir = join(root, "workflow-journal");
|
|
24
28
|
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
this.claimsDir = join(this.dir, "claims");
|
|
25
30
|
this.sharedKey = canonicalStoreKey(this.dir);
|
|
26
31
|
const existing = sharedJournalDirs.get(this.sharedKey);
|
|
27
32
|
if (existing !== undefined) {
|
|
@@ -87,7 +92,22 @@ export class FileWorkflowJournalStore {
|
|
|
87
92
|
let log = this.logs.get(runId);
|
|
88
93
|
if (log === undefined) {
|
|
89
94
|
log = new AppendLog(this.pathFor(runId));
|
|
90
|
-
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
this.logs.delete(runId);
|
|
98
|
+
}
|
|
99
|
+
this.logs.set(runId, log);
|
|
100
|
+
while (this.logs.size > MAX_OPEN_JOURNAL_LOGS) {
|
|
101
|
+
const coldest = this.logs.keys().next().value;
|
|
102
|
+
if (coldest === undefined || coldest === runId)
|
|
103
|
+
break;
|
|
104
|
+
const stale = this.logs.get(coldest);
|
|
105
|
+
this.logs.delete(coldest);
|
|
106
|
+
try {
|
|
107
|
+
stale?.close();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
}
|
|
91
111
|
}
|
|
92
112
|
const line = { scope, ordinal: callKeyOrdinal(entry.callKey), callKey: entry.callKey, result: entry.result };
|
|
93
113
|
log.append(line, this.fsyncEnabled);
|
|
@@ -98,6 +118,86 @@ export class FileWorkflowJournalStore {
|
|
|
98
118
|
return [];
|
|
99
119
|
return [...rec.byOrdinal.entries()].sort((a, b) => a[0] - b[0]).map(([, e]) => e);
|
|
100
120
|
}
|
|
121
|
+
claimPathFor(sourceRunId, scope) {
|
|
122
|
+
const scopeTag = createHash("sha256").update(scope).digest("hex").slice(0, 16);
|
|
123
|
+
return join(this.claimsDir, `${sanitizePathComponent(sourceRunId)}.${scopeTag}.json`);
|
|
124
|
+
}
|
|
125
|
+
readClaim(path) {
|
|
126
|
+
if (!existsSync(path))
|
|
127
|
+
return undefined;
|
|
128
|
+
try {
|
|
129
|
+
const rec = JSON.parse(readFileSync(path, "utf8"));
|
|
130
|
+
if (typeof rec.holder !== "string" || typeof rec.expiresAt !== "number" || !Number.isFinite(rec.expiresAt)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return rec;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async resumeClaim(input) {
|
|
140
|
+
const { sourceRunId, newRunId, scope } = input;
|
|
141
|
+
let path;
|
|
142
|
+
try {
|
|
143
|
+
path = this.claimPathFor(sourceRunId, scope);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return { granted: true };
|
|
147
|
+
}
|
|
148
|
+
mkdirSync(this.claimsDir, { recursive: true, mode: 0o700 });
|
|
149
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
150
|
+
const record = { sourceRunId, holder: newRunId, expiresAt: Date.now() + RESUME_CLAIM_TTL_MS };
|
|
151
|
+
try {
|
|
152
|
+
const fd = openSync(path, "wx", 0o600);
|
|
153
|
+
try {
|
|
154
|
+
writeSync(fd, JSON.stringify(record));
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
return { granted: true };
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
if (err.code !== "EEXIST")
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
const existing = this.readClaim(path);
|
|
166
|
+
if (existing !== undefined && existing.holder === newRunId) {
|
|
167
|
+
return { granted: true };
|
|
168
|
+
}
|
|
169
|
+
if (existing === undefined || existing.expiresAt <= Date.now()) {
|
|
170
|
+
try {
|
|
171
|
+
unlinkSync(path);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
return { granted: false, holder: existing.holder };
|
|
178
|
+
}
|
|
179
|
+
const winner = this.readClaim(path);
|
|
180
|
+
if (winner === undefined || winner.holder === newRunId)
|
|
181
|
+
return { granted: true };
|
|
182
|
+
return { granted: false, holder: winner.holder };
|
|
183
|
+
}
|
|
184
|
+
async releaseResumeClaim(input) {
|
|
185
|
+
let path;
|
|
186
|
+
try {
|
|
187
|
+
path = this.claimPathFor(input.sourceRunId, input.scope);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const existing = this.readClaim(path);
|
|
193
|
+
if (existing === undefined || existing.holder !== input.newRunId)
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
unlinkSync(path);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
}
|
|
200
|
+
}
|
|
101
201
|
async deleteByRun(runId) {
|
|
102
202
|
const log = this.logs.get(runId);
|
|
103
203
|
if (log !== undefined) {
|
|
@@ -159,7 +159,7 @@ export function classifyCompoundReadonly(command, allow) {
|
|
|
159
159
|
const deviceArgs = toks.slice(1).filter((t) => !t.startsWith("-")).map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
|
|
160
160
|
const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
|
|
161
161
|
if (!rescuedByHead && deviceArgs.length > 0) {
|
|
162
|
-
return "
|
|
162
|
+
return "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) — not auto-allowed";
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
return undefined;
|
package/dist/tools/fs/fs-read.js
CHANGED
|
@@ -250,10 +250,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
250
250
|
}
|
|
251
251
|
const truncated = start > 1 || end < total || pageMarker !== undefined;
|
|
252
252
|
const prev = state.get(r.key);
|
|
253
|
-
if (total > 0 && prev?.seededFromContext && start === 1 && effLimit === undefined && prev.hash === hash) {
|
|
253
|
+
if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
|
|
254
254
|
return seededFileUnchangedReminder(r.key);
|
|
255
255
|
}
|
|
256
|
-
if (total > 0 && prev && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
|
|
256
|
+
if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
|
|
257
257
|
return `[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`;
|
|
258
258
|
}
|
|
259
259
|
state.set(r.key, {
|
package/dist/tools/fs/safety.js
CHANGED
|
@@ -38,17 +38,23 @@ const BLOCKED_DEVICE_PATHS = new Set([
|
|
|
38
38
|
"/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/tty", "/dev/console",
|
|
39
39
|
"/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
|
|
40
40
|
]);
|
|
41
|
-
|
|
41
|
+
function isProcStdioFd(key) {
|
|
42
|
+
return key.startsWith("/proc/") && (key.endsWith("/fd/0") || key.endsWith("/fd/1") || key.endsWith("/fd/2"));
|
|
43
|
+
}
|
|
44
|
+
const PROC_SENSITIVE_RE = /^\/proc\/[^/]+\/(environ|cmdline|auxv|maps|mem|stat)$/;
|
|
42
45
|
const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.[^\\/]*)?$/i;
|
|
46
|
+
function isWinFormPath(p) {
|
|
47
|
+
return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\") || (!p.startsWith("/") && p.includes("\\"));
|
|
48
|
+
}
|
|
43
49
|
function isWinReservedDeviceKey(key) {
|
|
44
|
-
if (!(
|
|
50
|
+
if (!isWinFormPath(key))
|
|
45
51
|
return false;
|
|
46
52
|
const t = key.replace(/[\\/]+$/, "");
|
|
47
53
|
const base = t.slice(Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\")) + 1);
|
|
48
54
|
return WIN_RESERVED_RE.test(base);
|
|
49
55
|
}
|
|
50
56
|
export function isBlockedDevicePath(key) {
|
|
51
|
-
return BLOCKED_DEVICE_PATHS.has(key) ||
|
|
57
|
+
return BLOCKED_DEVICE_PATHS.has(key) || isProcStdioFd(key) || PROC_SENSITIVE_RE.test(key) || isWinReservedDeviceKey(key);
|
|
52
58
|
}
|
|
53
59
|
export function normalizeAbsPathLexically(p) {
|
|
54
60
|
if (!p.startsWith("/"))
|
|
@@ -168,10 +174,11 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
|
|
|
168
174
|
return { ok: true, key };
|
|
169
175
|
}
|
|
170
176
|
export async function canonicalizeTarget(env, path, signal, baseCwd) {
|
|
171
|
-
if (isUncPath(path)) {
|
|
172
|
-
return { ok:
|
|
177
|
+
if (isUncPath(path) && isWinFormPath(path)) {
|
|
178
|
+
return { ok: true, key: path };
|
|
173
179
|
}
|
|
174
|
-
const
|
|
180
|
+
const spelled = path.startsWith("//") ? path.replace(/^\/+/, "/") : path;
|
|
181
|
+
const target = baseCwd && !isAbsolutePathForm(spelled) ? `${baseCwd.replace(/[\\/]+$/, "")}/${spelled}` : spelled;
|
|
175
182
|
const absR = await env.absolutePath(target, signal);
|
|
176
183
|
if (!absR.ok)
|
|
177
184
|
return { ok: false, message: `cannot resolve path "${path}": ${absR.error.message}` };
|
|
@@ -20,5 +20,6 @@ export interface TaskListStore {
|
|
|
20
20
|
}
|
|
21
21
|
export declare function normalizeTaskShape<T>(item: T): T;
|
|
22
22
|
export declare function assertJsonMetadata(value: unknown, path?: string): void;
|
|
23
|
+
export declare function compareTaskIds(a: string, b: string): number;
|
|
23
24
|
export declare function createMemoryTaskListStore(): TaskListStore;
|
|
24
25
|
export declare function createTaskListTools(store?: TaskListStore): ToolSpec[];
|
package/dist/tools/task-list.js
CHANGED
|
@@ -55,6 +55,17 @@ export function assertJsonMetadata(value, path = "metadata") {
|
|
|
55
55
|
}
|
|
56
56
|
throw new Error(`Task ${path} must be JSON-serializable (found ${t === "object" ? "non-plain object" : t}).`);
|
|
57
57
|
}
|
|
58
|
+
export function compareTaskIds(a, b) {
|
|
59
|
+
const num = (id) => (/^\d+$/.test(id) ? Number.parseInt(id, 10) : Number.NaN);
|
|
60
|
+
const [na, nb] = [num(a), num(b)];
|
|
61
|
+
if (Number.isFinite(na) && Number.isFinite(nb))
|
|
62
|
+
return na - nb || a.localeCompare(b);
|
|
63
|
+
if (Number.isFinite(na))
|
|
64
|
+
return -1;
|
|
65
|
+
if (Number.isFinite(nb))
|
|
66
|
+
return 1;
|
|
67
|
+
return a.localeCompare(b);
|
|
68
|
+
}
|
|
58
69
|
export function createMemoryTaskListStore() {
|
|
59
70
|
const tasks = new Map();
|
|
60
71
|
let nextId = 1;
|
|
@@ -69,7 +80,7 @@ export function createMemoryTaskListStore() {
|
|
|
69
80
|
tasks.set(id, snapTask(item));
|
|
70
81
|
},
|
|
71
82
|
delete: (id) => tasks.delete(id),
|
|
72
|
-
list: () => [...tasks.values()].map(snapTask),
|
|
83
|
+
list: () => [...tasks.values()].sort((a, b) => compareTaskIds(a.id, b.id)).map(snapTask),
|
|
73
84
|
allocateId: () => String(nextId++),
|
|
74
85
|
};
|
|
75
86
|
}
|
|
@@ -133,7 +144,7 @@ export function createTaskListTools(store) {
|
|
|
133
144
|
activeForm: Type.Optional(Type.String({ description: 'Present continuous form shown when in_progress (e.g., "Running tests")' })),
|
|
134
145
|
metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Arbitrary metadata to attach to the task" })),
|
|
135
146
|
}),
|
|
136
|
-
effect: "
|
|
147
|
+
effect: "write",
|
|
137
148
|
execute: (args) => serialized(tasks, async (tx) => {
|
|
138
149
|
const a = args;
|
|
139
150
|
if (a.metadata)
|
package/dist/tools/web.js
CHANGED
|
@@ -577,6 +577,13 @@ export function createWebFetchSummarizer(brain, model) {
|
|
|
577
577
|
const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
|
|
578
578
|
const SEARCH_ERROR_EXCERPT_CHARS = 2048;
|
|
579
579
|
const SEARCH_QUERY_MAX_CHARS = 2_000;
|
|
580
|
+
const DETAILS_QUERY_ECHO_MAX_CHARS = 500;
|
|
581
|
+
function clipQueryEcho(query) {
|
|
582
|
+
const cps = [...query];
|
|
583
|
+
return cps.length > DETAILS_QUERY_ECHO_MAX_CHARS
|
|
584
|
+
? cps.slice(0, DETAILS_QUERY_ECHO_MAX_CHARS).join("") + `…[${cps.length} chars total]`
|
|
585
|
+
: query;
|
|
586
|
+
}
|
|
580
587
|
function classifySearchFailure(message) {
|
|
581
588
|
const m = message.toLowerCase();
|
|
582
589
|
const status = /(?:\bhttp\b|\bstatus\b|\bcode\b|\berror\b)\D{0,12}?(\d{3})\b/.exec(m)?.[1];
|
|
@@ -619,6 +626,18 @@ function hostMatchesDomain(host, domain) {
|
|
|
619
626
|
return false;
|
|
620
627
|
return h === d || h.endsWith("." + d);
|
|
621
628
|
}
|
|
629
|
+
const URL_SCHEME_PREFIX = /^([a-z][a-z0-9+.-]*):/i;
|
|
630
|
+
function normalizeResultUrl(raw) {
|
|
631
|
+
const s = raw.trim();
|
|
632
|
+
if (!s)
|
|
633
|
+
return { url: raw, schemeAssumed: false };
|
|
634
|
+
if (s.startsWith("//"))
|
|
635
|
+
return { url: "https:" + s, schemeAssumed: true };
|
|
636
|
+
const scheme = URL_SCHEME_PREFIX.exec(s)?.[1];
|
|
637
|
+
if (scheme !== undefined && !scheme.includes("."))
|
|
638
|
+
return { url: s, schemeAssumed: false };
|
|
639
|
+
return { url: "https://" + s, schemeAssumed: true };
|
|
640
|
+
}
|
|
622
641
|
function webSearchResultAllowed(url, allowed, blocked) {
|
|
623
642
|
let host;
|
|
624
643
|
try {
|
|
@@ -662,7 +681,7 @@ export function createWebSearchTool(config) {
|
|
|
662
681
|
const startedAt = Date.now();
|
|
663
682
|
const failCard = (extra = {}) => ({
|
|
664
683
|
type: "web-search",
|
|
665
|
-
query,
|
|
684
|
+
query: clipQueryEcho(query),
|
|
666
685
|
results: [],
|
|
667
686
|
durationSeconds: (Date.now() - startedAt) / 1000,
|
|
668
687
|
searchCount: 0,
|
|
@@ -723,13 +742,18 @@ export function createWebSearchTool(config) {
|
|
|
723
742
|
clearTimeout(timer);
|
|
724
743
|
ctx.signal?.removeEventListener("abort", onOuterAbort);
|
|
725
744
|
}
|
|
726
|
-
const
|
|
727
|
-
|
|
745
|
+
const normalized = results.map((r) => {
|
|
746
|
+
const n = normalizeResultUrl(r.url);
|
|
747
|
+
return { ...r, url: n.url, schemeAssumed: n.schemeAssumed };
|
|
748
|
+
});
|
|
749
|
+
const usable = normalized.filter((r) => webSearchResultAllowed(r.url));
|
|
750
|
+
const droppedUnusable = normalized.length - usable.length;
|
|
728
751
|
const domainFiltered = usable.filter((r) => webSearchResultAllowed(r.url, allowed_domains, blocked_domains));
|
|
729
752
|
const droppedByDomain = usable.length - domainFiltered.length;
|
|
730
|
-
|
|
753
|
+
const kept = domainFiltered.slice(0, max);
|
|
754
|
+
const schemeAssumed = kept.filter((r) => r.schemeAssumed).length;
|
|
731
755
|
let clipped = false;
|
|
732
|
-
const shown =
|
|
756
|
+
const shown = kept.map((r) => {
|
|
733
757
|
const title = clipCodePoints(r.title, SEARCH_TITLE_MAX_CHARS);
|
|
734
758
|
const url = clipCodePoints(r.url, MAX_URL_LENGTH);
|
|
735
759
|
const snippet = clipCodePoints(r.snippet, SEARCH_SNIPPET_MAX_CHARS);
|
|
@@ -750,7 +774,13 @@ export function createWebSearchTool(config) {
|
|
|
750
774
|
const clipNote = clipped
|
|
751
775
|
? "\n\n[WebSearch: one or more result fields exceeded the display limits and were truncated — treat the text above as an excerpt, not the backend's full result.]"
|
|
752
776
|
: "";
|
|
753
|
-
const
|
|
777
|
+
const schemeNote = schemeAssumed > 0
|
|
778
|
+
? `\n\n[WebSearch: ${schemeAssumed} result(s) came back with no URL scheme (a bare domain or a protocol-relative URL) and are shown normalized to https:// — that scheme is this tool's assumption, not the backend's claim.]`
|
|
779
|
+
: "";
|
|
780
|
+
const tailReminder = shown.length > 0
|
|
781
|
+
? "REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks."
|
|
782
|
+
: "REMINDER: this search returned ZERO usable sources — there is nothing above to cite. Do not fabricate sources, URLs or citations; tell the user the search returned no usable results (the notes above say why, when there is a why) and answer from what you already know, or try a different query.";
|
|
783
|
+
const modelText = `${fenced}${dropNote}${domainNote}${schemeNote}${clipNote}\n\n${tailReminder}`;
|
|
754
784
|
return {
|
|
755
785
|
content: modelText,
|
|
756
786
|
details: {
|