@sema-agent/core 5.1.0 → 5.2.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 (47) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/agents/roster-store.js +6 -1
  3. package/dist/bin/sema-tb.js +3 -4
  4. package/dist/brain/openai.js +3 -5
  5. package/dist/brain/terminal-cause.d.ts +1 -1
  6. package/dist/core/hooks.js +8 -3
  7. package/dist/core/mcp.d.ts +1 -1
  8. package/dist/core/memory-engine/dual-root.js +2 -1
  9. package/dist/core/memory-engine/engine.d.ts +4 -0
  10. package/dist/core/memory-engine/engine.js +25 -6
  11. package/dist/core/memory-engine/file-backend.d.ts +7 -5
  12. package/dist/core/memory-engine/file-backend.js +2 -2
  13. package/dist/core/memory-engine/index.d.ts +1 -1
  14. package/dist/core/memory-engine/index.js +1 -1
  15. package/dist/core/memory-engine/layout.d.ts +6 -2
  16. package/dist/core/memory-engine/layout.js +73 -31
  17. package/dist/core/memory.js +6 -0
  18. package/dist/core/protocol-table.d.ts +10 -7
  19. package/dist/core/protocol-table.js +28 -14
  20. package/dist/core/runner/prepare-memory.js +4 -4
  21. package/dist/core/runner/prepare-task.js +30 -14
  22. package/dist/core/runner/runtask.js +11 -5
  23. package/dist/core/runner/tool-disclosure.js +6 -1
  24. package/dist/core/tool-policy.d.ts +1 -0
  25. package/dist/core/tool-policy.js +12 -2
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.js +1 -1
  28. package/dist/orchestration/run-workflow-tool.js +5 -1
  29. package/dist/prompt-assembly/assemble.js +0 -1
  30. package/dist/stores/cc/mailbox-store.js +58 -14
  31. package/dist/stores/file/file-snapshot-store.d.ts +9 -1
  32. package/dist/stores/file/file-snapshot-store.js +28 -5
  33. package/dist/stores/file/fs-atomic.d.ts +4 -1
  34. package/dist/stores/file/fs-atomic.js +2 -1
  35. package/dist/stores/file/index.d.ts +10 -3
  36. package/dist/stores/file/index.js +4 -3
  37. package/dist/stores/file/mailbox-store.d.ts +5 -0
  38. package/dist/stores/file/mailbox-store.js +15 -3
  39. package/dist/stores/file/session-policy-store.d.ts +11 -8
  40. package/dist/stores/file/session-policy-store.js +21 -4
  41. package/dist/stores/file/session-store.d.ts +9 -1
  42. package/dist/stores/file/session-store.js +19 -4
  43. package/dist/tools/fs/fs-search-tools.js +9 -0
  44. package/dist/tools/fs/fs-shared.d.ts +1 -1
  45. package/dist/tools/fs/fs-shared.js +1 -1
  46. package/dist/tools/todo.js +13 -6
  47. package/package.json +1 -1
@@ -18,7 +18,8 @@ export class FileFileSnapshotStore {
18
18
  }
19
19
  inFlightKey;
20
20
  bounds;
21
- constructor(root, bounds) {
21
+ onCorruptRead;
22
+ constructor(root, bounds, opts) {
22
23
  this.base = join(root, "file-snapshots");
23
24
  this.blobsDir = join(this.base, "blobs");
24
25
  this.manifestsDir = join(this.base, "manifests");
@@ -30,6 +31,14 @@ export class FileFileSnapshotStore {
30
31
  maxBytes: bounds?.maxBytes ?? DEFAULT_SNAPSHOT_BOUNDS.maxBytes,
31
32
  ignoreDirs: new Set(bounds?.ignoreDirs ?? DEFAULT_SNAPSHOT_BOUNDS.ignoreDirs),
32
33
  };
34
+ this.onCorruptRead = opts?.onCorruptRead;
35
+ }
36
+ disclose(path, reason) {
37
+ try {
38
+ this.onCorruptRead?.({ path, reason });
39
+ }
40
+ catch {
41
+ }
33
42
  }
34
43
  scopeDir(scope) {
35
44
  return join(this.manifestsDir, sanitizeScope(scope));
@@ -60,9 +69,13 @@ export class FileFileSnapshotStore {
60
69
  }
61
70
  try {
62
71
  const parsed = JSON.parse(raw);
63
- return Array.isArray(parsed) ? new Map(parsed) : null;
72
+ if (Array.isArray(parsed))
73
+ return new Map(parsed);
74
+ this.disclose(path, "manifest is not a pair array — read as no snapshot");
75
+ return null;
64
76
  }
65
77
  catch {
78
+ this.disclose(path, "unparseable manifest JSON (torn/zero-byte write?) — read as no snapshot");
66
79
  return null;
67
80
  }
68
81
  }
@@ -132,6 +145,7 @@ export class FileFileSnapshotStore {
132
145
  catch (err) {
133
146
  if (err.code === "ENOENT")
134
147
  return [];
148
+ this.disclose(dir, `manifest scope directory unreadable (${err.code ?? "unknown"}) — listed as no keys`);
135
149
  return [];
136
150
  }
137
151
  return entries
@@ -242,8 +256,10 @@ export class FileFileSnapshotStore {
242
256
  catch (err) {
243
257
  if (err.code === "ENOENT")
244
258
  entries = [];
245
- else
259
+ else {
246
260
  entries = [];
261
+ this.disclose(dir, `manifest scope directory unreadable (${err.code ?? "unknown"}) — the manifest-deletion pass was skipped`);
262
+ }
247
263
  }
248
264
  for (const file of entries) {
249
265
  if (!file.endsWith(".json"))
@@ -266,8 +282,10 @@ export class FileFileSnapshotStore {
266
282
  catch (err) {
267
283
  if (err.code === "ENOENT")
268
284
  scopeDirs = [];
269
- else
285
+ else {
286
+ this.disclose(this.manifestsDir, `manifest root unreadable (${err.code ?? "unknown"}) — blob GC aborted, every blob kept`);
270
287
  return removed;
288
+ }
271
289
  }
272
290
  for (const scopeName of scopeDirs) {
273
291
  const sdir = join(this.manifestsDir, scopeName);
@@ -279,6 +297,7 @@ export class FileFileSnapshotStore {
279
297
  if (err.code === "ENOENT")
280
298
  continue;
281
299
  complete = false;
300
+ this.disclose(sdir, `manifest scope directory unreadable (${err.code ?? "unknown"}) — blob GC aborted, every blob kept`);
282
301
  break;
283
302
  }
284
303
  for (const file of files) {
@@ -287,6 +306,7 @@ export class FileFileSnapshotStore {
287
306
  const m = this.readManifest(join(sdir, file));
288
307
  if (!m) {
289
308
  complete = false;
309
+ this.disclose(join(sdir, file), "manifest unreadable during the live-set scan — blob GC aborted, every blob kept");
290
310
  break;
291
311
  }
292
312
  for (const h of m.values())
@@ -301,7 +321,10 @@ export class FileFileSnapshotStore {
301
321
  try {
302
322
  blobs = readdirSync(this.blobsDir);
303
323
  }
304
- catch {
324
+ catch (err) {
325
+ if (err.code !== "ENOENT") {
326
+ this.disclose(this.blobsDir, `blob directory unreadable (${err.code ?? "unknown"}) — blob GC skipped, every blob kept`);
327
+ }
305
328
  return removed;
306
329
  }
307
330
  for (const blob of blobs) {
@@ -4,7 +4,10 @@ export declare function resolveDataRoot(explicit?: string): string;
4
4
  export declare function writeThenLink(target: string, content: string | Uint8Array): void;
5
5
  export declare function ensureDir(dir: string): void;
6
6
  export declare function atomicWriteFile(tmpDir: string, target: string, bytes: string): void;
7
- export declare function readJsonlRecords<T>(path: string): T[];
7
+ export declare function readJsonlRecords<T>(path: string, onCorrupt?: (info: {
8
+ path: string;
9
+ reason: string;
10
+ }) => void): T[];
8
11
  export declare function canonicalStoreKey(p: string): string;
9
12
  export declare class AppendLog {
10
13
  private fd;
@@ -101,7 +101,7 @@ function basenameOf(p) {
101
101
  function dirnameOf(p) {
102
102
  return dirname(p);
103
103
  }
104
- export function readJsonlRecords(path) {
104
+ export function readJsonlRecords(path, onCorrupt) {
105
105
  let raw;
106
106
  try {
107
107
  raw = readFileSync(path, "utf8");
@@ -126,6 +126,7 @@ export function readJsonlRecords(path) {
126
126
  out.push(JSON.parse(line));
127
127
  }
128
128
  catch {
129
+ onCorrupt?.({ path, reason: `unparseable jsonl record at line ${i + 1} (skipped; the rest of the replay is intact)` });
129
130
  }
130
131
  }
131
132
  return out;
@@ -9,10 +9,10 @@ import type { FileSnapshotStore } from "../../core/file-snapshot-store.js";
9
9
  import type { WorkflowJournalStore } from "../../core/workflow-journal-store.js";
10
10
  export { FileCheckpointStore, type FileCheckpointStoreOptions } from "./checkpoint-store.js";
11
11
  export { FileMemoryStore } from "./memory-store.js";
12
- export { FileSessionRepo } from "./session-store.js";
12
+ export { FileSessionRepo, type FileSessionRepoOptions } from "./session-store.js";
13
13
  export { FileToolResultStore } from "./tool-result-store.js";
14
- export { FileSessionPolicyStore } from "./session-policy-store.js";
15
- export { FileFileSnapshotStore } from "./file-snapshot-store.js";
14
+ export { FileSessionPolicyStore, type FileSessionPolicyStoreOptions, type SessionPolicyCorruptReadInfo } from "./session-policy-store.js";
15
+ export { FileFileSnapshotStore, type FileFileSnapshotStoreOptions } from "./file-snapshot-store.js";
16
16
  export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "./workflow-journal-store.js";
17
17
  export { resolveDataRoot, sanitizeScope, sanitizePathComponent, createFileConsolidationLock } from "./fs-atomic.js";
18
18
  export { atomicWriteFile, writeThenLink, ensureDir, readJsonlRecords, AppendLog } from "./fs-atomic.js";
@@ -23,6 +23,13 @@ export interface FileStorageBackendOptions {
23
23
  checkpoint?: FileCheckpointStoreOptions;
24
24
  snapshotBounds?: Partial<import("../../core/file-snapshot-store.js").FileSnapshotBounds>;
25
25
  embedder?: import("../../core/memory.js").Embedder;
26
+ onCorruptRead?: (info: FileStorageCorruptReadInfo) => void;
27
+ }
28
+ export interface FileStorageCorruptReadInfo {
29
+ path: string;
30
+ reason: string;
31
+ sessionId?: string;
32
+ principal?: string;
26
33
  }
27
34
  export declare class FileStorageBackend {
28
35
  readonly root: string;
@@ -39,7 +39,8 @@ export class FileStorageBackend {
39
39
  this.lock = new BootLock(join(this.root, "LOCK"));
40
40
  this.lock.acquire();
41
41
  try {
42
- const repo = new FileSessionRepo(this.root);
42
+ const corruptRead = opts.onCorruptRead !== undefined ? { onCorruptRead: opts.onCorruptRead } : undefined;
43
+ const repo = new FileSessionRepo(this.root, corruptRead);
43
44
  this.fileSessions = repo;
44
45
  this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget" });
45
46
  this.sessionStore = this.ttl;
@@ -48,8 +49,8 @@ export class FileStorageBackend {
48
49
  this.fileMemory = new FileMemoryStore(this.root, opts.embedder ? { embedder: opts.embedder } : {});
49
50
  this.memoryStore = guardedMemoryStore(this.fileMemory, opts.utilityGate);
50
51
  this.toolResultStore = new FileToolResultStore(this.root);
51
- this.sessionPolicyStore = new FileSessionPolicyStore(this.root);
52
- this.fileSnapshotStore = new FileFileSnapshotStore(this.root, opts.snapshotBounds);
52
+ this.sessionPolicyStore = new FileSessionPolicyStore(this.root, corruptRead);
53
+ this.fileSnapshotStore = new FileFileSnapshotStore(this.root, opts.snapshotBounds, corruptRead);
53
54
  this.fileWorkflowJournal = new FileWorkflowJournalStore(this.root);
54
55
  this.workflowJournalStore = this.fileWorkflowJournal;
55
56
  this.consolidationLock = createFileConsolidationLock(join(this.root, "consolidation-locks"));
@@ -2,6 +2,10 @@ import { type MailboxAppendMessage, type MailboxLease, type MailboxStore } from
2
2
  export interface FileMailboxStoreOptions {
3
3
  fsync?: boolean;
4
4
  compactEvery?: number;
5
+ onCorruptRead?: (info: {
6
+ path: string;
7
+ reason: string;
8
+ }) => void;
5
9
  }
6
10
  export declare class FileMailboxStore implements MailboxStore {
7
11
  private readonly dir;
@@ -9,6 +13,7 @@ export declare class FileMailboxStore implements MailboxStore {
9
13
  private readonly fsyncEnabled;
10
14
  private readonly compactEvery;
11
15
  private readonly touched;
16
+ private readonly discloseCorrupt;
12
17
  constructor(root: string, opts?: FileMailboxStoreOptions);
13
18
  private boxPath;
14
19
  private lockKey;
@@ -39,9 +39,21 @@ export class FileMailboxStore {
39
39
  fsyncEnabled;
40
40
  compactEvery;
41
41
  touched = new Map();
42
+ discloseCorrupt;
42
43
  constructor(root, opts = {}) {
43
44
  this.fsyncEnabled = opts.fsync !== false;
44
45
  this.compactEvery = opts.compactEvery ?? 500;
46
+ const sink = opts.onCorruptRead;
47
+ this.discloseCorrupt =
48
+ sink === undefined
49
+ ? undefined
50
+ : (info) => {
51
+ try {
52
+ sink(info);
53
+ }
54
+ catch {
55
+ }
56
+ };
45
57
  const lexicalDir = resolve(join(root, "mailboxes"));
46
58
  const lexicalTmp = resolve(join(root, "tmp"));
47
59
  ensureDir(lexicalDir);
@@ -67,7 +79,7 @@ export class FileMailboxStore {
67
79
  return b;
68
80
  }
69
81
  ensureDir(join(this.dir, sanitizeScope(scope)));
70
- const events = readJsonlRecords(path);
82
+ const events = readJsonlRecords(path, this.discloseCorrupt);
71
83
  const state = { messages: [], nextSeq: 1, log: new AppendLog(path), events: events.length, refs: 1, path };
72
84
  for (const ev of events)
73
85
  applyEvent(state, ev);
@@ -145,7 +157,7 @@ export class FileMailboxStore {
145
157
  return 0;
146
158
  return withPathLock(this.lockKey(scope, handle), () => {
147
159
  const b = { messages: [], nextSeq: 1 };
148
- for (const ev of readJsonlRecords(path))
160
+ for (const ev of readJsonlRecords(path, this.discloseCorrupt))
149
161
  applyEvent(b, ev);
150
162
  return b.messages.length;
151
163
  });
@@ -200,7 +212,7 @@ export class FileMailboxStore {
200
212
  if (sharedBoxes.has(key))
201
213
  return false;
202
214
  const b = { messages: [], nextSeq: 1 };
203
- for (const ev of readJsonlRecords(path))
215
+ for (const ev of readJsonlRecords(path, this.discloseCorrupt))
204
216
  applyEvent(b, ev);
205
217
  const newest = newestSentAt(b.messages);
206
218
  if (newest === undefined || newest >= now - maxAgeMs)
@@ -1,15 +1,18 @@
1
1
  import { type PutRulesOptions, type SessionPermissionRules, type SessionPolicyStore, type SessionRulesRecord, type StoredSessionRules } from "../../core/session-policy-store.js";
2
+ export interface SessionPolicyCorruptReadInfo {
3
+ sessionId: string;
4
+ principal?: string;
5
+ path: string;
6
+ reason: string;
7
+ }
8
+ export interface FileSessionPolicyStoreOptions {
9
+ onCorruptRead?: (info: SessionPolicyCorruptReadInfo) => void;
10
+ }
2
11
  export declare class FileSessionPolicyStore implements SessionPolicyStore {
3
12
  private readonly dir;
4
13
  private readonly onCorruptRead;
5
- constructor(root: string, opts?: {
6
- onCorruptRead?: (info: {
7
- sessionId: string;
8
- principal?: string;
9
- path: string;
10
- reason: string;
11
- }) => void;
12
- });
14
+ constructor(root: string, opts?: FileSessionPolicyStoreOptions);
15
+ private disclose;
13
16
  private discloseCorrupt;
14
17
  private pathFor;
15
18
  private read;
@@ -10,13 +10,16 @@ export class FileSessionPolicyStore {
10
10
  ensureDir(this.dir);
11
11
  this.onCorruptRead = opts?.onCorruptRead;
12
12
  }
13
- discloseCorrupt(sessionId, principal, reason) {
13
+ disclose(info) {
14
14
  try {
15
- this.onCorruptRead?.({ sessionId, ...(principal !== undefined ? { principal } : {}), path: this.pathFor(sessionId, principal), reason });
15
+ this.onCorruptRead?.(info);
16
16
  }
17
17
  catch {
18
18
  }
19
19
  }
20
+ discloseCorrupt(sessionId, principal, reason) {
21
+ this.disclose({ sessionId, ...(principal !== undefined ? { principal } : {}), path: this.pathFor(sessionId, principal), reason });
22
+ }
20
23
  pathFor(sessionId, principal) {
21
24
  const composite = JSON.stringify([sessionId, principal ?? null]);
22
25
  return join(this.dir, `${sanitizeScope(composite)}.json`);
@@ -90,15 +93,29 @@ export class FileSessionPolicyStore {
90
93
  for (const file of files) {
91
94
  if (!file.endsWith(".json"))
92
95
  continue;
96
+ const full = join(this.dir, file);
97
+ let raw;
98
+ try {
99
+ raw = readFileSync(full, "utf8");
100
+ }
101
+ catch (err) {
102
+ if (err.code !== "ENOENT") {
103
+ this.disclose({ sessionId, path: full, reason: `read failed (non-ENOENT): ${err.message}` });
104
+ }
105
+ continue;
106
+ }
93
107
  let parsed;
94
108
  try {
95
- parsed = JSON.parse(readFileSync(join(this.dir, file), "utf8"));
109
+ parsed = JSON.parse(raw);
96
110
  }
97
111
  catch {
112
+ this.disclose({ sessionId, path: full, reason: "unparseable JSON (torn/zero-byte write?)" });
98
113
  continue;
99
114
  }
100
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
115
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
116
+ this.disclose({ sessionId, path: full, reason: "not a JSON object" });
101
117
  continue;
118
+ }
102
119
  const r = parsed;
103
120
  if (r.__sid !== sessionId)
104
121
  continue;
@@ -1,9 +1,17 @@
1
1
  import type { Session, SessionForkOptions, SessionMetadata, SessionRepo, SessionTreeEntry } from "../../internal/harness.js";
2
+ export interface FileSessionRepoOptions {
3
+ onCorruptRead?: (info: {
4
+ path: string;
5
+ reason: string;
6
+ }) => void;
7
+ }
2
8
  export declare class FileSessionRepo implements SessionRepo {
3
9
  private readonly dir;
4
10
  private readonly tmpDir;
11
+ private readonly onCorruptRead;
5
12
  private readonly joined;
6
- constructor(root: string);
13
+ constructor(root: string, opts?: FileSessionRepoOptions);
14
+ private disclose;
7
15
  private pathFor;
8
16
  private read;
9
17
  private storage;
@@ -54,12 +54,21 @@ class FileSessionStorage extends BaseSessionStorage {
54
54
  export class FileSessionRepo {
55
55
  dir;
56
56
  tmpDir;
57
+ onCorruptRead;
57
58
  joined = new Map();
58
- constructor(root) {
59
+ constructor(root, opts) {
59
60
  this.dir = join(root, "sessions");
60
61
  this.tmpDir = join(root, "tmp");
61
62
  ensureDir(this.dir);
62
63
  ensureDir(this.tmpDir);
64
+ this.onCorruptRead = opts?.onCorruptRead;
65
+ }
66
+ disclose(path, reason) {
67
+ try {
68
+ this.onCorruptRead?.({ path, reason });
69
+ }
70
+ catch {
71
+ }
63
72
  }
64
73
  pathFor(id) {
65
74
  return join(this.dir, `${sanitizePathComponent(id)}${SUFFIX}`);
@@ -69,7 +78,7 @@ export class FileSessionRepo {
69
78
  if (!existsSync(path)) {
70
79
  throw new SessionError("not_found", `Session not found: ${id}`);
71
80
  }
72
- const lines = readJsonlRecords(path);
81
+ const lines = readJsonlRecords(path, (info) => this.disclose(info.path, info.reason));
73
82
  let createdAt = "";
74
83
  let forkedFrom;
75
84
  const entries = [];
@@ -132,7 +141,10 @@ export class FileSessionRepo {
132
141
  try {
133
142
  names = readdirSync(this.dir);
134
143
  }
135
- catch {
144
+ catch (err) {
145
+ if (err.code !== "ENOENT") {
146
+ this.disclose(this.dir, `session directory unreadable (${err.code ?? "unknown"}) — listed as empty`);
147
+ }
136
148
  return [];
137
149
  }
138
150
  const out = [];
@@ -144,7 +156,10 @@ export class FileSessionRepo {
144
156
  const { createdAt, forkedFrom } = this.read(id);
145
157
  out.push({ id, createdAt, ...(forkedFrom !== undefined ? { forkedFrom } : {}) });
146
158
  }
147
- catch {
159
+ catch (err) {
160
+ if (!(err instanceof SessionError && err.code === "not_found")) {
161
+ this.disclose(this.pathFor(id), `session log unreadable (${err instanceof Error ? err.message : String(err)}) — skipped from the listing`);
162
+ }
148
163
  }
149
164
  }
150
165
  return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
@@ -40,6 +40,15 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
40
40
  }),
41
41
  effect: "read",
42
42
  execute: async (args, ctx) => {
43
+ const retired = args;
44
+ for (const [name, repair] of [
45
+ ["regex", "the `pattern` is ALWAYS evaluated as a regular expression (ripgrep semantics) — drop the key; to match text literally, escape the regex metacharacters in `pattern`"],
46
+ ["max_results", "cap the result count with `head_limit` (0 = unlimited), paginating with `offset`"],
47
+ ]) {
48
+ if (retired[name] !== undefined) {
49
+ return errorResult(`Error (Grep): \`${name}\` is not a Grep parameter (retired) — ${repair}. Nothing was searched.`);
50
+ }
51
+ }
43
52
  const a = args;
44
53
  for (const [name, value] of [
45
54
  ["head_limit", a.head_limit],
@@ -42,7 +42,7 @@ export declare function bashTimeoutCapsSec(caps: {
42
42
  };
43
43
  export declare function bashMaxOutputChars(): number;
44
44
  export declare const FILE_PATH_PARAMS: {
45
- file_path: Type.TOptional<Type.TString>;
45
+ file_path: Type.TString;
46
46
  };
47
47
  export declare function clipShellOutput(s: string): string;
48
48
  export declare function writeShellOverflowFile(env: ExecutionEnv, stdout: string, stderr: string): Promise<string | undefined>;
@@ -76,7 +76,7 @@ export function bashMaxOutputChars() {
76
76
  return Math.min(Math.floor(raw), BASH_MAX_OUTPUT_CHARS_CEILING);
77
77
  }
78
78
  export const FILE_PATH_PARAMS = {
79
- file_path: Type.Optional(Type.String({ description: "File path (within the configured root)." })),
79
+ file_path: Type.String({ description: "File path (within the configured root)." }),
80
80
  };
81
81
  export function clipShellOutput(s) {
82
82
  return clipWithFilePointer(s, bashMaxOutputChars());
@@ -1,4 +1,5 @@
1
1
  import { Type } from "typebox";
2
+ import { errorResult } from "../core/tools.js";
2
3
  export function createTodoWriteTool() {
3
4
  let todos = [];
4
5
  return {
@@ -6,7 +7,7 @@ export function createTodoWriteTool() {
6
7
  contract: { contractId: "core.todo_write@1", implementationRevision: "1" },
7
8
  description: "Create and update a task list for the current session. The list is rendered to the user as your working plan.\n" +
8
9
  "\n" +
9
- '- Each todo has `content`, `status` ("pending" | "in_progress" | "completed"), and optionally `activeForm` (present-tense label shown while in progress).\n' +
10
+ '- Each todo has `content`, `status` ("pending" | "in_progress" | "completed"), and `activeForm` (present-tense label shown while in progress).\n' +
10
11
  "- Send the full list each call; it replaces the previous one.\n" +
11
12
  "- Keep one item `in_progress` at a time and mark it `completed` when done.",
12
13
  descriptionClassic: "Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.\n" +
@@ -81,13 +82,19 @@ export function createTodoWriteTool() {
81
82
  }),
82
83
  effect: "idempotent",
83
84
  execute: (args) => {
84
- const shape = (t) => ({
85
- content: t.content,
86
- status: t.status,
87
- activeForm: t.activeForm && t.activeForm.length > 0 ? t.activeForm : t.content,
88
- });
85
+ const shape = (t) => ({ content: t.content, status: t.status, activeForm: t.activeForm });
89
86
  const oldTodos = todos.map(shape);
90
87
  const written = args.todos;
88
+ for (let i = 0; i < written.length; i++) {
89
+ const activeForm = written[i].activeForm;
90
+ if (typeof activeForm !== "string" || activeForm.length === 0) {
91
+ const what = activeForm === undefined ? "is missing" : "has an empty or non-string";
92
+ return errorResult(`Error (TodoWrite): todo ${i + 1} of ${written.length} ${what} \`activeForm\` — every todo needs the ` +
93
+ `present-continuous label shown while it runs (e.g. content "Run the tests" → activeForm "Running the ` +
94
+ `tests"), alongside its imperative \`content\`. Nothing was stored; resend the COMPLETE list with the ` +
95
+ `label filled in on every item.`);
96
+ }
97
+ }
91
98
  const allDone = written.every((t) => t.status === "completed");
92
99
  todos = allDone ? [] : written;
93
100
  const newTodos = written.map(shape);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",