@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.
Files changed (35) hide show
  1. package/dist/agents/observer.js +8 -3
  2. package/dist/agents/send-message-tool.js +112 -81
  3. package/dist/agents/subagent.d.ts +10 -4
  4. package/dist/agents/subagent.js +103 -53
  5. package/dist/core/memory-engine/dual-root.js +2 -0
  6. package/dist/core/memory-engine/engine.d.ts +4 -0
  7. package/dist/core/memory-engine/engine.js +6 -1
  8. package/dist/core/runner/prepare-memory.d.ts +4 -0
  9. package/dist/core/runner/prepare-memory.js +4 -1
  10. package/dist/core/runner/prepare-task.d.ts +8 -2
  11. package/dist/core/runner/prepare-task.js +39 -8
  12. package/dist/core/runner/runtask.js +910 -865
  13. package/dist/core/runner/turn-attachments.d.ts +15 -1
  14. package/dist/core/runner/turn-attachments.js +68 -9
  15. package/dist/core/tool-result-budget.js +2 -2
  16. package/dist/core/tool-result-store.d.ts +2 -0
  17. package/dist/core/tool-result-store.js +27 -2
  18. package/dist/core/types.d.ts +1 -1
  19. package/dist/core/workflow-journal-store.d.ts +13 -0
  20. package/dist/engine/session/import-validate.js +29 -0
  21. package/dist/orchestration/workflow.js +28 -1
  22. package/dist/prompt-assembly/event-registry.js +1 -1
  23. package/dist/stores/cc/task-list-store.js +3 -3
  24. package/dist/stores/file/memory-store.d.ts +3 -0
  25. package/dist/stores/file/memory-store.js +39 -12
  26. package/dist/stores/file/tool-result-store.js +16 -2
  27. package/dist/stores/file/workflow-journal-store.d.ts +17 -0
  28. package/dist/stores/file/workflow-journal-store.js +102 -2
  29. package/dist/tools/fs/bash-readonly-classifier.js +1 -1
  30. package/dist/tools/fs/fs-read.js +2 -2
  31. package/dist/tools/fs/safety.js +13 -6
  32. package/dist/tools/task-list.d.ts +1 -0
  33. package/dist/tools/task-list.js +13 -2
  34. package/dist/tools/web.js +36 -6
  35. 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
- this.logs.set(runId, log);
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 "an unbounded device source (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, …) would block the pipeline until the tool timeout — not auto-allowed";
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;
@@ -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, {
@@ -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
- const PROC_FD_RE = /^\/proc\/[^/]+\/fd\/[0-2]$/;
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 (!(/^[A-Za-z]:[\\/]/.test(key) || key.startsWith("\\\\") || (!key.startsWith("/") && key.includes("\\"))))
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) || PROC_FD_RE.test(key) || isWinReservedDeviceKey(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: false, message: `path "${path}" is a UNC/network path; refused (potential credential leak).` };
177
+ if (isUncPath(path) && isWinFormPath(path)) {
178
+ return { ok: true, key: path };
173
179
  }
174
- const target = baseCwd && !isAbsolutePathForm(path) ? `${baseCwd.replace(/[\\/]+$/, "")}/${path}` : path;
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[];
@@ -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: "idempotent",
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 usable = results.filter((r) => webSearchResultAllowed(r.url));
727
- const droppedUnusable = results.length - usable.length;
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
- results = domainFiltered.slice(0, max);
753
+ const kept = domainFiltered.slice(0, max);
754
+ const schemeAssumed = kept.filter((r) => r.schemeAssumed).length;
731
755
  let clipped = false;
732
- const shown = results.map((r) => {
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 modelText = `${fenced}${dropNote}${domainNote}${clipNote}\n\nREMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.`;
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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",