@arnilo/prism 0.0.4 → 0.0.6

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 (97) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +34 -10
  3. package/dist/agent-loops.d.ts +1 -0
  4. package/dist/agent-loops.js +26 -16
  5. package/dist/agents.js +147 -21
  6. package/dist/cli-init.d.ts +41 -0
  7. package/dist/cli-init.js +390 -0
  8. package/dist/cli-runner.d.ts +7 -1
  9. package/dist/cli-runner.js +13 -1
  10. package/dist/content.d.ts +19 -0
  11. package/dist/content.js +197 -69
  12. package/dist/contracts.d.ts +96 -9
  13. package/dist/contracts.js +8 -0
  14. package/dist/feedback.d.ts +48 -0
  15. package/dist/feedback.js +230 -0
  16. package/dist/ids.d.ts +2 -0
  17. package/dist/ids.js +6 -0
  18. package/dist/index.d.ts +10 -4
  19. package/dist/index.js +6 -3
  20. package/dist/providers/media.d.ts +3 -1
  21. package/dist/providers/media.js +11 -1
  22. package/dist/session-stores.js +2 -3
  23. package/dist/testing/feedback.d.ts +6 -0
  24. package/dist/testing/feedback.js +37 -0
  25. package/dist/testing/persistence-schema.d.ts +48 -10
  26. package/dist/testing/persistence-schema.js +166 -22
  27. package/dist/testing/run-ledger-conformance.js +7 -1
  28. package/dist/thinking.d.ts +42 -0
  29. package/dist/thinking.js +92 -0
  30. package/dist/tools.js +2 -3
  31. package/dist/use-case-model.d.ts +63 -0
  32. package/dist/use-case-model.js +52 -0
  33. package/docs/a2a.md +75 -0
  34. package/docs/agent-events.md +14 -21
  35. package/docs/agent-loops.md +12 -9
  36. package/docs/agent-session-runtime.md +14 -16
  37. package/docs/cli-rpc.md +35 -7
  38. package/docs/coding-agent-tools.md +35 -14
  39. package/docs/coding-security.md +7 -3
  40. package/docs/compaction-llm.md +17 -7
  41. package/docs/compaction-observational-memory.md +30 -4
  42. package/docs/context-and-skills.md +1 -0
  43. package/docs/credential-storage.md +58 -9
  44. package/docs/credentials-and-redaction.md +3 -3
  45. package/docs/database-persistence.md +17 -9
  46. package/docs/evaluations.md +122 -0
  47. package/docs/extensions.md +2 -2
  48. package/docs/host-security.md +26 -5
  49. package/docs/index.md +43 -28
  50. package/docs/mcp-tools.md +74 -13
  51. package/docs/migration.md +177 -3
  52. package/docs/multimodal-content.md +14 -6
  53. package/docs/node-filesystem-config.md +1 -0
  54. package/docs/node-jsonl-session-store.md +5 -4
  55. package/docs/observability.md +14 -6
  56. package/docs/performance.md +209 -0
  57. package/docs/postgres-persistence.md +8 -6
  58. package/docs/provider-caching.md +16 -4
  59. package/docs/provider-conformance.md +40 -1
  60. package/docs/provider-packages.md +62 -3
  61. package/docs/providers/ai-sdk.md +149 -0
  62. package/docs/providers/kimi.md +124 -61
  63. package/docs/providers/neuralwatt.md +19 -13
  64. package/docs/providers/openai.md +56 -13
  65. package/docs/providers/opencode-go.md +118 -30
  66. package/docs/providers/openrouter.md +105 -35
  67. package/docs/providers/zai.md +94 -45
  68. package/docs/public-contracts.md +6 -5
  69. package/docs/rag.md +113 -0
  70. package/docs/release-and-install.md +100 -79
  71. package/docs/review-coverage-2026-07-15.md +193 -0
  72. package/docs/review-coverage-2026-07-17-provider-validation.md +192 -0
  73. package/docs/runs-and-usage.md +42 -5
  74. package/docs/server.md +139 -0
  75. package/docs/settings-auth-trust-security.md +5 -5
  76. package/docs/sqlite-persistence.md +6 -5
  77. package/docs/structured-output.md +1 -1
  78. package/docs/supervisors.md +71 -0
  79. package/docs/thinking-and-reasoning.md +98 -0
  80. package/docs/tool-execution-primitives.md +3 -3
  81. package/docs/tools.md +15 -0
  82. package/docs/use-case-model-selection.md +109 -0
  83. package/docs/workflow-orchestration-primitives.md +20 -3
  84. package/docs/workflows.md +114 -33
  85. package/docs/working-and-semantic-memory.md +170 -0
  86. package/package.json +13 -3
  87. package/templates/init/README.md.tmpl +28 -0
  88. package/templates/init/env.example.tmpl +1 -0
  89. package/templates/init/gitignore.tmpl +11 -0
  90. package/templates/init/optional/evals-example.ts.tmpl +17 -0
  91. package/templates/init/optional/workflows-example.ts.tmpl +27 -0
  92. package/templates/init/package.json.tmpl +22 -0
  93. package/templates/init/providers.json +76 -0
  94. package/templates/init/src/agent.ts.tmpl +10 -0
  95. package/templates/init/src/index.ts.tmpl +12 -0
  96. package/templates/init/src/tests/agent.test.ts.tmpl +24 -0
  97. package/templates/init/tsconfig.json.tmpl +15 -0
@@ -1,9 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  // ponytail: dialect-neutral persistence schema model and migration contracts for
2
3
  // SQLite/PostgreSQL adapter packages (Plan 056 Task 1). SQL stays package-local;
3
4
  // this module defines the shared table/index/pagination/migration expectations
4
5
  // adapter authors implement and test against before shipping dialect-specific DDL.
5
6
  /** Current shared persistence schema version for production database adapters. */
6
- export const PERSISTENCE_SCHEMA_VERSION = 1;
7
+ export const PERSISTENCE_SCHEMA_VERSION = 3;
7
8
  /** Guidance adapters must follow: values are bound parameters, never interpolated. */
8
9
  export const PARAMETERIZED_QUERY_GUIDANCE = "Bind every user-supplied value (session ids, idempotency keys, tenant ids, timestamps, JSON payloads) as a query parameter. Quote/validate schema and table identifiers only; never interpolate untrusted strings into SQL text.";
9
10
  const TENANT_COLUMNS = [
@@ -107,7 +108,7 @@ export function createPersistenceSchemaModel() {
107
108
  { name: "run_id", type: "text", nullable: true },
108
109
  { name: "timestamp", type: "timestamp" },
109
110
  { name: "kind", type: "text" },
110
- { name: "schema_version", type: "integer" },
111
+ { name: "schema_version", type: "integer", nullable: true },
111
112
  { name: "message", type: "json", nullable: true },
112
113
  { name: "event", type: "json", nullable: true },
113
114
  { name: "model", type: "json", nullable: true },
@@ -156,7 +157,6 @@ export function createPersistenceSchemaModel() {
156
157
  ...TENANT_COLUMNS,
157
158
  { name: "metadata", type: "json", nullable: true },
158
159
  ],
159
- uniqueKeys: [["tenant_id", "idempotency_key"]],
160
160
  foreignKeys: [{ columns: ["session_id"], referencesTable: "prism_sessions", referencesColumns: ["id"] }],
161
161
  },
162
162
  {
@@ -210,6 +210,9 @@ export function createPersistenceSchemaModel() {
210
210
  { name: "session_id", type: "text" },
211
211
  { name: "run_id", type: "text", nullable: true },
212
212
  { name: "entry_id", type: "text", nullable: true },
213
+ { name: "scope", type: "text", defaultValue: "'run_total'" },
214
+ { name: "turn", type: "integer", nullable: true },
215
+ { name: "attempt", type: "integer", nullable: true },
213
216
  { name: "usage", type: "json" },
214
217
  { name: "recorded_at", type: "timestamp" },
215
218
  ...TENANT_COLUMNS,
@@ -217,6 +220,28 @@ export function createPersistenceSchemaModel() {
217
220
  ],
218
221
  foreignKeys: [{ columns: ["session_id"], referencesTable: "prism_sessions", referencesColumns: ["id"] }],
219
222
  },
223
+ {
224
+ name: "prism_run_feedback",
225
+ primaryKey: ["id"],
226
+ columns: [
227
+ { name: "id", type: "text" },
228
+ { name: "run_id", type: "text" },
229
+ { name: "session_id", type: "text" },
230
+ { name: "trace_id", type: "text", nullable: true },
231
+ { name: "rating", type: "number", nullable: true },
232
+ { name: "comment", type: "text", nullable: true },
233
+ { name: "tags", type: "json" },
234
+ { name: "scorer_ids", type: "json" },
235
+ { name: "evaluation_ids", type: "json" },
236
+ { name: "created_at", type: "timestamp" },
237
+ { name: "created_by", type: "text", nullable: true },
238
+ { name: "tenant_id", type: "text", tenantScoped: true },
239
+ { name: "account_id", type: "text", nullable: true, tenantScoped: true },
240
+ { name: "user_id", type: "text", nullable: true, tenantScoped: true },
241
+ { name: "metadata", type: "json", nullable: true },
242
+ ],
243
+ foreignKeys: [{ columns: ["run_id"], referencesTable: "prism_runs", referencesColumns: ["id"] }],
244
+ },
220
245
  {
221
246
  name: "prism_retention_policies",
222
247
  primaryKey: ["id"],
@@ -258,7 +283,7 @@ export function createPersistenceSchemaModel() {
258
283
  { name: "prism_session_entries_session_run_ts_idx", table: "prism_session_entries", columns: ["session_id", "run_id", "timestamp"], purpose: "run-scoped entry listing" },
259
284
  { name: "prism_session_entries_session_ts_id_idx", table: "prism_session_entries", columns: ["session_id", "timestamp", "id"], purpose: "cursor pagination without full scans" },
260
285
  { name: "prism_session_entries_session_id_idx", table: "prism_session_entries", columns: ["session_id", "id"], purpose: "append parent validation and recursive branch reads" },
261
- { name: "prism_session_append_idempotency_unique", table: "prism_session_append_idempotency", columns: ["session_id", "expected_parent_id", "idempotency_key"], unique: true, purpose: "append retry deduplication" },
286
+ { name: "prism_session_append_idempotency_unique", table: "prism_session_append_idempotency", columns: ["session_id", "expected_parent_id", "idempotency_key"], purpose: "append retry deduplication (primary key enforces uniqueness)" },
262
287
  { name: "prism_runs_session_started_idx", table: "prism_runs", columns: ["session_id", "started_at", "id"], purpose: "run history pagination" },
263
288
  { name: "prism_runs_branch_started_idx", table: "prism_runs", columns: ["branch_id", "started_at", "id"], purpose: "branch-scoped runs" },
264
289
  { name: "prism_runs_tenant_idempotency_unique", table: "prism_runs", columns: ["tenant_id", "idempotency_key"], unique: true, purpose: "run-level idempotency deduplication per tenant" },
@@ -268,18 +293,45 @@ export function createPersistenceSchemaModel() {
268
293
  { name: "prism_tool_calls_run_started_idx", table: "prism_tool_calls", columns: ["run_id", "started_at"], purpose: "run tool-call listing" },
269
294
  { name: "prism_usage_run_recorded_idx", table: "prism_usage", columns: ["run_id", "recorded_at", "id"], purpose: "run usage pagination" },
270
295
  { name: "prism_usage_session_recorded_idx", table: "prism_usage", columns: ["session_id", "recorded_at"], purpose: "usage aggregation" },
296
+ { name: "prism_usage_session_scope_recorded_idx", table: "prism_usage", columns: ["session_id", "scope", "recorded_at"], purpose: "scope-safe usage aggregation" },
297
+ { name: "prism_run_feedback_owner_created_idx", table: "prism_run_feedback", columns: ["tenant_id", "account_id", "user_id", "created_at", "id"], purpose: "ownership-scoped feedback pagination" },
298
+ { name: "prism_run_feedback_run_created_idx", table: "prism_run_feedback", columns: ["run_id", "created_at", "id"], purpose: "run feedback lookup" },
299
+ { name: "prism_run_feedback_trace_created_idx", table: "prism_run_feedback", columns: ["trace_id", "created_at", "id"], purpose: "trace feedback lookup" },
271
300
  { name: "prism_agent_definitions_name_version_idx", table: "prism_agent_definitions", columns: ["name", "version"], purpose: "definition lookup" },
272
- { name: "prism_migrations_name_version_idx", table: "prism_migrations", columns: ["name", "version"], unique: true, purpose: "applied-migration uniqueness" },
301
+ { name: "prism_migrations_name_version_idx", table: "prism_migrations", columns: ["name", "version"], purpose: "applied-migration lookup (table constraint enforces uniqueness)" },
273
302
  ],
274
303
  };
275
304
  }
305
+ function migrationStep(version, name, description) {
306
+ const model = createPersistenceSchemaModel();
307
+ const content = version === 1
308
+ ? {
309
+ tables: model.tables
310
+ .filter((table) => table.name !== "prism_run_feedback")
311
+ .map((table) => table.name === "prism_usage"
312
+ ? { ...table, columns: table.columns.filter((column) => !["scope", "turn", "attempt"].includes(column.name)) }
313
+ : table),
314
+ indexes: model.indexes.filter((index) => !index.name.startsWith("prism_usage_session_scope_") && !index.name.startsWith("prism_run_feedback_")),
315
+ }
316
+ : version === 2
317
+ ? { table: "prism_usage", columns: ["scope", "turn", "attempt"], indexes: ["prism_usage_session_scope_recorded_idx"] }
318
+ : { tables: ["prism_run_feedback"], indexes: model.indexes.filter((index) => index.name.startsWith("prism_run_feedback_")).map((index) => index.name) };
319
+ return {
320
+ version,
321
+ name,
322
+ description,
323
+ checksum: createHash("sha256").update(JSON.stringify({ version, name, content })).digest("hex"),
324
+ };
325
+ }
276
326
  /** Canonical migration contract for production adapters. */
277
327
  export function createPersistenceMigrationContract() {
278
328
  return {
279
329
  targetSchemaVersion: PERSISTENCE_SCHEMA_VERSION,
280
330
  appliedMigrationsTable: "prism_migrations",
281
331
  steps: [
282
- { version: 1, name: "001_init", description: "Create core session, branch, entry, idempotency, run, ledger, and migration tables." },
332
+ migrationStep(1, "001_init", "Create core session, branch, entry, idempotency, run, ledger, and migration tables."),
333
+ migrationStep(2, "002_usage_scope", "Distinguish provider-turn usage from aggregate run totals."),
334
+ migrationStep(3, "003_run_feedback", "Add immutable ownership-scoped run/trace feedback and evaluation links."),
283
335
  ],
284
336
  lockGuidance: "Acquire a dialect-specific migration lock before applying steps (PostgreSQL advisory lock; SQLite exclusive transaction). Only one process should migrate at a time.",
285
337
  leastPrivilegeGuidance: "Run migrations with a DDL-capable role; use a separate least-privilege runtime role limited to INSERT/SELECT/UPDATE on adapter tables. Never grant migration credentials to the agent runtime.",
@@ -294,6 +346,7 @@ export function getPersistencePaginationCursors() {
294
346
  { table: "prism_agent_events", columns: ["session_id", "timestamp", "id"], supportsOrder: ["asc", "desc"], purpose: "session event stream" },
295
347
  { table: "prism_usage", columns: ["run_id", "recorded_at", "id"], supportsOrder: ["asc", "desc"], purpose: "run usage totals" },
296
348
  { table: "prism_tool_calls", columns: ["run_id", "started_at"], supportsOrder: ["asc", "desc"], purpose: "run tool-call listing" },
349
+ { table: "prism_run_feedback", columns: ["tenant_id", "account_id", "user_id", "created_at", "id"], supportsOrder: ["asc", "desc"], purpose: "owned feedback listing" },
297
350
  ];
298
351
  }
299
352
  /** Build a tenant-scoped unique key column list for adapter DDL. */
@@ -326,7 +379,7 @@ export function assertPersistenceSchemaModel(model) {
326
379
  throw new Error("Runs table must include tenant-scoped tenant_id");
327
380
  }
328
381
  const indexTables = new Set(model.indexes.map((index) => index.table));
329
- for (const requiredIndex of ["prism_session_append_idempotency", "prism_session_entries", "prism_agent_events", "prism_runs"]) {
382
+ for (const requiredIndex of ["prism_session_append_idempotency", "prism_session_entries", "prism_agent_events", "prism_runs", "prism_run_feedback"]) {
330
383
  if (!indexTables.has(requiredIndex)) {
331
384
  throw new Error(`Persistence schema missing indexes for ${requiredIndex}`);
332
385
  }
@@ -357,6 +410,8 @@ export function assertPersistenceMigrationContract(contract) {
357
410
  throw new Error(`Migration steps must be strictly increasing; ${step.name} is out of order`);
358
411
  if (names.has(step.name))
359
412
  throw new Error(`Duplicate migration step name: ${step.name}`);
413
+ if (!/^[a-f0-9]{64}$/.test(step.checksum))
414
+ throw new Error(`Migration step ${step.name} must have a SHA-256 checksum`);
360
415
  names.add(step.name);
361
416
  previous = step.version;
362
417
  }
@@ -364,6 +419,101 @@ export function assertPersistenceMigrationContract(contract) {
364
419
  throw new Error(`Last migration step version ${previous} must equal targetSchemaVersion ${contract.targetSchemaVersion}`);
365
420
  }
366
421
  }
422
+ function schemaKey(columns) {
423
+ return columns.join("\u0000");
424
+ }
425
+ function normalizedDefault(value) {
426
+ return value?.trim().toLowerCase().replace(/::[a-z_ ]+$/, "");
427
+ }
428
+ function compatibleColumnType(dialect, actual, expected) {
429
+ const type = actual.trim().toUpperCase();
430
+ if (dialect === "sqlite") {
431
+ if (expected === "integer" || expected === "boolean")
432
+ return type === "INTEGER";
433
+ if (expected === "number")
434
+ return type === "REAL";
435
+ return type === "TEXT";
436
+ }
437
+ if (expected === "integer")
438
+ return type === "INTEGER";
439
+ if (expected === "boolean")
440
+ return type === "BOOLEAN";
441
+ if (expected === "number")
442
+ return type === "DOUBLE PRECISION";
443
+ return type === "TEXT";
444
+ }
445
+ /** Compare bounded dialect catalog output against every required schema-v3 detail. */
446
+ export function assertPersistenceSchemaShape(shape, dialect, model = createPersistenceSchemaModel()) {
447
+ assertPersistenceSchemaModel(model);
448
+ const actualTables = new Map(shape.tables.map((table) => [table.name, table]));
449
+ for (const expected of model.tables) {
450
+ const actual = actualTables.get(expected.name);
451
+ if (!actual)
452
+ throw new Error(`Persistence schema missing table ${expected.name}`);
453
+ if (actual.columns.length !== expected.columns.length)
454
+ throw new Error(`Persistence schema table ${expected.name} has unexpected columns`);
455
+ const actualColumns = new Map(actual.columns.map((column) => [column.name, column]));
456
+ for (const column of expected.columns) {
457
+ const found = actualColumns.get(column.name);
458
+ if (!found)
459
+ throw new Error(`Persistence schema table ${expected.name} missing column ${column.name}`);
460
+ if (!compatibleColumnType(dialect, found.type, column.type)) {
461
+ throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible type`);
462
+ }
463
+ if (found.nullable !== (column.nullable === true)) {
464
+ throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible nullability`);
465
+ }
466
+ if (normalizedDefault(found.defaultValue) !== normalizedDefault(column.defaultValue)) {
467
+ throw new Error(`Persistence schema column ${expected.name}.${column.name} has incompatible default`);
468
+ }
469
+ }
470
+ if (schemaKey(actual.primaryKey) !== schemaKey(expected.primaryKey)) {
471
+ throw new Error(`Persistence schema table ${expected.name} has incompatible primary key`);
472
+ }
473
+ const unique = new Set(actual.uniqueKeys.map(schemaKey));
474
+ for (const key of expected.uniqueKeys ?? []) {
475
+ if (!unique.has(schemaKey(key)) && schemaKey(actual.primaryKey) !== schemaKey(key)) {
476
+ throw new Error(`Persistence schema table ${expected.name} missing unique key (${key.join(", ")})`);
477
+ }
478
+ }
479
+ const foreign = new Set(actual.foreignKeys.map((key) => `${schemaKey(key.columns)}>${key.referencesTable}:${schemaKey(key.referencesColumns)}`));
480
+ for (const key of expected.foreignKeys ?? []) {
481
+ if (!foreign.has(`${schemaKey(key.columns)}>${key.referencesTable}:${schemaKey(key.referencesColumns)}`)) {
482
+ throw new Error(`Persistence schema table ${expected.name} missing foreign key (${key.columns.join(", ")})`);
483
+ }
484
+ }
485
+ }
486
+ const actualIndexes = new Map(shape.indexes.map((index) => [index.name, index]));
487
+ for (const expected of model.indexes) {
488
+ const actual = actualIndexes.get(expected.name);
489
+ if (!actual)
490
+ throw new Error(`Persistence schema missing required index ${expected.name}`);
491
+ if (actual.table !== expected.table || schemaKey(actual.columns) !== schemaKey(expected.columns) || actual.unique !== (expected.unique === true)) {
492
+ throw new Error(`Persistence schema index ${expected.name} has incompatible definition`);
493
+ }
494
+ }
495
+ }
496
+ /** Reject altered migration history before any new DDL or runtime write. */
497
+ export function assertAppliedPersistenceMigrations(contract, applied) {
498
+ assertPersistenceMigrationContract(contract);
499
+ if (applied.length > contract.steps.length)
500
+ throw new Error("Migration history has unknown rows");
501
+ const legacyChecksums = applied.some((row) => row.checksum === null);
502
+ if (legacyChecksums && (applied.length !== contract.steps.length || !applied.every((row) => row.checksum === null))) {
503
+ throw new Error("Migration history has incomplete legacy checksums");
504
+ }
505
+ for (let index = 0; index < applied.length; index++) {
506
+ const row = applied[index];
507
+ const expected = contract.steps[index];
508
+ if (row.name !== expected.name || row.version !== String(expected.version)) {
509
+ throw new Error(`Migration history row ${index} does not match ${expected.name}`);
510
+ }
511
+ if (!legacyChecksums && row.checksum !== expected.checksum) {
512
+ throw new Error(`Migration history checksum mismatch for ${expected.name}`);
513
+ }
514
+ }
515
+ return { legacyChecksums };
516
+ }
367
517
  /**
368
518
  * Assert a dialect-local adapter exposes the canonical table and index names.
369
519
  * Adapters pass the table/index names their migration runner created.
@@ -393,23 +543,17 @@ export function assertParameterizedQuery(sql, boundValues) {
393
543
  }
394
544
  /** Simulate migration up + reopen: applied steps must match the contract in order. */
395
545
  export function assertMigrationUpAndReopen(contract, appliedAfterUp, appliedAfterReopen) {
396
- assertPersistenceMigrationContract(contract);
397
- if (appliedAfterUp.length !== contract.steps.length) {
398
- throw new Error("Migration up did not apply every contract step");
399
- }
400
- for (let i = 0; i < contract.steps.length; i++) {
401
- const step = contract.steps[i];
402
- const row = appliedAfterUp[i];
403
- if (row.name !== step.name || row.version !== String(step.version)) {
404
- throw new Error(`Applied migration row ${i} does not match contract step ${step.name}`);
405
- }
546
+ const first = assertAppliedPersistenceMigrations(contract, appliedAfterUp);
547
+ if (appliedAfterUp.length !== contract.steps.length || first.legacyChecksums) {
548
+ throw new Error("Migration up did not apply every checksummed contract step");
406
549
  }
407
- if (appliedAfterReopen.length !== appliedAfterUp.length) {
408
- throw new Error("Reopened adapter must not re-apply migrations; applied row count changed");
550
+ const second = assertAppliedPersistenceMigrations(contract, appliedAfterReopen);
551
+ if (second.legacyChecksums || appliedAfterReopen.length !== appliedAfterUp.length) {
552
+ throw new Error("Reopened adapter migration history diverged");
409
553
  }
410
- for (let i = 0; i < appliedAfterUp.length; i++) {
411
- if (appliedAfterReopen[i].name !== appliedAfterUp[i].name) {
412
- throw new Error("Reopened adapter migration history diverged");
554
+ for (let index = 0; index < appliedAfterUp.length; index++) {
555
+ if (appliedAfterReopen[index].checksum !== appliedAfterUp[index].checksum) {
556
+ throw new Error("Reopened adapter migration checksums diverged");
413
557
  }
414
558
  }
415
559
  }
@@ -71,6 +71,9 @@ export async function assertRunLedgerConforms(fixture, options = {}) {
71
71
  id: "usage-1",
72
72
  sessionId,
73
73
  runId,
74
+ scope: "provider_turn",
75
+ turn: 1,
76
+ attempt: 2,
74
77
  usage: { inputTokens: 3, outputTokens: 5, totalTokens: 8 },
75
78
  recordedAt: "2026-01-01T00:00:01.000Z",
76
79
  ...scope,
@@ -114,8 +117,11 @@ export async function assertRunLedgerConforms(fixture, options = {}) {
114
117
  }
115
118
  if (fixture.readUsage) {
116
119
  const usageRows = await fixture.readUsage();
117
- if (!usageRows.some((row) => row.id === "usage-1")) {
120
+ const storedUsage = usageRows.find((row) => row.id === "usage-1");
121
+ if (!storedUsage)
118
122
  throw new Error("RunLedger must persist UsageRecord rows");
123
+ if (storedUsage.scope !== "provider_turn" || storedUsage.turn !== 1 || storedUsage.attempt !== 2) {
124
+ throw new Error("RunLedger must preserve UsageRecord scope, turn, and attempt");
119
125
  }
120
126
  }
121
127
  if (options.exerciseTenantIsolation && fixture.readRuns) {
@@ -0,0 +1,42 @@
1
+ import type { JsonObject, ModelConfig, ProviderRequestOptions } from "./contracts.js";
2
+ /**
3
+ * Portable thinking / reasoning effort levels shared across first-party providers.
4
+ * Model-dependent legality (which values a given model accepts) stays provider-owned.
5
+ */
6
+ export declare const THINKING_LEVELS: readonly ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
7
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
8
+ /**
9
+ * Compat mapping families used by ≥2 packages, or explicit no-op for host-owned adapters.
10
+ * Provider packages keep unique escape hatches (budgets, keep/all, tool_stream) local.
11
+ */
12
+ export type ThinkingCompatFamily = "openai_reasoning" | "reasoning_effort" | "thinking_type" | "noop";
13
+ export declare function isThinkingLevel(value: unknown): value is ThinkingLevel;
14
+ /**
15
+ * Normalize a host thinkingLevel string. Known levels are lowercased; other non-empty
16
+ * strings pass through as opaque effort values for forward-compatible provider fields.
17
+ */
18
+ export declare function normalizeThinkingLevel(level: string): ThinkingLevel | string | undefined;
19
+ /**
20
+ * Build the `ProviderRequestOptions.compat` patch for a shared thinking level.
21
+ * Does not invent a second options tree — providers keep reading official fields from `compat`.
22
+ */
23
+ export declare function thinkingCompatFor(family: ThinkingCompatFamily, level: ThinkingLevel | string): JsonObject;
24
+ /**
25
+ * Merge a shared thinking level into `providerOptions.compat` for the given family.
26
+ * Per-turn patches win over prior compat via {@link mergeProviderRequestOptions}.
27
+ */
28
+ export declare function applyThinkingLevel(options: ProviderRequestOptions | undefined, level: ThinkingLevel | string, family?: ThinkingCompatFamily): ProviderRequestOptions;
29
+ /**
30
+ * Best-effort family inference from model metadata without a second options tree.
31
+ * Prefer an explicit family in hosts/use-case workers when the provider is known.
32
+ *
33
+ * Heuristics (ordered):
34
+ * 1. Existing `compat.thinking` object → `thinking_type`
35
+ * 2. Existing `compat.reasoning` → `openai_reasoning`
36
+ * 3. Existing `compat.reasoning_effort` → `reasoning_effort`
37
+ * 4. Provider id starting with `openai` → `openai_reasoning`
38
+ * 5. Provider id `neuralwatt` → `reasoning_effort`
39
+ * 6. `capabilities.reasoning` → `reasoning_effort` (portable string field)
40
+ * 7. Else `noop`
41
+ */
42
+ export declare function thinkingFamilyForModel(model: Pick<ModelConfig, "provider" | "compat" | "capabilities">): ThinkingCompatFamily;
@@ -0,0 +1,92 @@
1
+ import { mergeProviderRequestOptions } from "./provider-request-policy.js";
2
+ /**
3
+ * Portable thinking / reasoning effort levels shared across first-party providers.
4
+ * Model-dependent legality (which values a given model accepts) stays provider-owned.
5
+ */
6
+ export const THINKING_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
7
+ export function isThinkingLevel(value) {
8
+ return typeof value === "string" && THINKING_LEVELS.includes(value);
9
+ }
10
+ /**
11
+ * Normalize a host thinkingLevel string. Known levels are lowercased; other non-empty
12
+ * strings pass through as opaque effort values for forward-compatible provider fields.
13
+ */
14
+ export function normalizeThinkingLevel(level) {
15
+ const normalized = level.trim().toLowerCase();
16
+ if (!normalized)
17
+ return undefined;
18
+ return isThinkingLevel(normalized) ? normalized : normalized;
19
+ }
20
+ /**
21
+ * Build the `ProviderRequestOptions.compat` patch for a shared thinking level.
22
+ * Does not invent a second options tree — providers keep reading official fields from `compat`.
23
+ */
24
+ export function thinkingCompatFor(family, level) {
25
+ const normalized = typeof level === "string" ? normalizeThinkingLevel(level) : level;
26
+ if (!normalized || family === "noop")
27
+ return {};
28
+ switch (family) {
29
+ case "openai_reasoning":
30
+ return { reasoning: { effort: normalized } };
31
+ case "reasoning_effort":
32
+ return { reasoning_effort: normalized };
33
+ case "thinking_type":
34
+ return { thinking: { type: normalized === "none" ? "disabled" : "enabled" } };
35
+ default: {
36
+ const _exhaustive = family;
37
+ return _exhaustive;
38
+ }
39
+ }
40
+ }
41
+ /**
42
+ * Merge a shared thinking level into `providerOptions.compat` for the given family.
43
+ * Per-turn patches win over prior compat via {@link mergeProviderRequestOptions}.
44
+ */
45
+ export function applyThinkingLevel(options, level, family = "reasoning_effort") {
46
+ const normalized = normalizeThinkingLevel(String(level));
47
+ if (!normalized || family === "noop")
48
+ return options ?? {};
49
+ const patch = thinkingCompatFor(family, normalized);
50
+ if (family === "openai_reasoning" && options?.compat?.reasoning && typeof options.compat.reasoning === "object" && !Array.isArray(options.compat.reasoning)) {
51
+ return mergeProviderRequestOptions(options, {
52
+ compat: {
53
+ reasoning: {
54
+ ...options.compat.reasoning,
55
+ ...patch.reasoning,
56
+ },
57
+ },
58
+ });
59
+ }
60
+ return mergeProviderRequestOptions(options, { compat: patch });
61
+ }
62
+ /**
63
+ * Best-effort family inference from model metadata without a second options tree.
64
+ * Prefer an explicit family in hosts/use-case workers when the provider is known.
65
+ *
66
+ * Heuristics (ordered):
67
+ * 1. Existing `compat.thinking` object → `thinking_type`
68
+ * 2. Existing `compat.reasoning` → `openai_reasoning`
69
+ * 3. Existing `compat.reasoning_effort` → `reasoning_effort`
70
+ * 4. Provider id starting with `openai` → `openai_reasoning`
71
+ * 5. Provider id `neuralwatt` → `reasoning_effort`
72
+ * 6. `capabilities.reasoning` → `reasoning_effort` (portable string field)
73
+ * 7. Else `noop`
74
+ */
75
+ export function thinkingFamilyForModel(model) {
76
+ const compat = model.compat ?? {};
77
+ if (compat.thinking != null && typeof compat.thinking === "object")
78
+ return "thinking_type";
79
+ if (compat.reasoning != null)
80
+ return "openai_reasoning";
81
+ if (compat.reasoning_effort != null)
82
+ return "reasoning_effort";
83
+ const provider = model.provider.trim().toLowerCase();
84
+ if (provider === "openai" || provider.startsWith("openai"))
85
+ return "openai_reasoning";
86
+ if (provider === "neuralwatt")
87
+ return "reasoning_effort";
88
+ if (model.capabilities?.reasoning)
89
+ return "reasoning_effort";
90
+ return "noop";
91
+ }
92
+ //# sourceMappingURL=thinking.js.map
package/dist/tools.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isJsonObject } from "./config.js";
2
+ import { createId } from "./ids.js";
2
3
  import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "./redaction.js";
3
4
  import { assertCanRegister } from "./registry-options.js";
4
5
  import { assertPermission } from "./security.js";
@@ -149,9 +150,7 @@ async function blocked(call, context, reason, error, options, startedAt) {
149
150
  function toErrorInfo(value, secrets) {
150
151
  return typeof value === "string" ? errorToErrorInfo(value, secrets) : redactSecrets(value, secrets);
151
152
  }
152
- function randomId(prefix) {
153
- return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
154
- }
153
+ const randomId = createId;
155
154
  function appendToolCallRecord(options, status, call, startedAt, fields) {
156
155
  if (!options.ledger)
157
156
  return undefined;
@@ -0,0 +1,63 @@
1
+ import type { ModelConfig, ProviderRequestOptions } from "./contracts.js";
2
+ /**
3
+ * Host binding for a non-session LLM job (observational memory, LLM compaction,
4
+ * declarative agents, evals, etc.). Omitting `model` means "use the session model"
5
+ * when a session fallback is supplied to {@link resolveUseCaseModel}.
6
+ *
7
+ * Workers must not write `model_change` session entries; they resolve a model for
8
+ * their own provider calls only.
9
+ */
10
+ export interface UseCaseModelBinding {
11
+ /** Explicit use-case model. When omitted, {@link resolveUseCaseModel} falls back to `sessionModel`. */
12
+ readonly model?: ModelConfig;
13
+ /**
14
+ * Optional provider id hint for docs / credential routing.
15
+ * When `model` is set, `model.provider` is authoritative.
16
+ */
17
+ readonly provider?: string;
18
+ readonly providerOptions?: ProviderRequestOptions;
19
+ /** Portable thinking level; packages map via `applyThinkingLevel` into `compat`. */
20
+ readonly thinkingLevel?: string;
21
+ /**
22
+ * When true, do not fall back to `sessionModel` — leave resolution empty if
23
+ * `model` is omitted (preserves historical explicit-worker `missing_model` behavior).
24
+ */
25
+ readonly requireExplicitModel?: boolean;
26
+ }
27
+ export interface ResolveUseCaseModelInput {
28
+ /** Explicit use-case model (or `binding.model`). */
29
+ readonly configured?: ModelConfig;
30
+ /** Active session / agent model used when `configured` is omitted. */
31
+ readonly sessionModel?: ModelConfig;
32
+ /** When true, skip session fallback (OM `missing_model` escape hatch). */
33
+ readonly requireExplicitModel?: boolean;
34
+ readonly providerOptions?: ProviderRequestOptions;
35
+ readonly thinkingLevel?: string;
36
+ }
37
+ export interface ResolvedUseCaseModel {
38
+ readonly model: ModelConfig;
39
+ /** Whether the model came from the use-case binding or session fallback. */
40
+ readonly source: "configured" | "session";
41
+ readonly providerOptions?: ProviderRequestOptions;
42
+ readonly thinkingLevel?: string;
43
+ }
44
+ /**
45
+ * Resolve the model for a non-session LLM job.
46
+ *
47
+ * Precedence:
48
+ * 1. `configured` → `source: "configured"`
49
+ * 2. Else `sessionModel` when `requireExplicitModel` is not set → `source: "session"`
50
+ * 3. Else `undefined` (caller skips / throws per package policy)
51
+ *
52
+ * O(1); no network. Does not mutate session history.
53
+ */
54
+ export declare function resolveUseCaseModel(input: ResolveUseCaseModelInput): ResolvedUseCaseModel | undefined;
55
+ /**
56
+ * Resolve from a {@link UseCaseModelBinding} plus optional session fallback.
57
+ */
58
+ export declare function resolveUseCaseModelBinding(binding: UseCaseModelBinding | undefined, sessionModel?: ModelConfig): ResolvedUseCaseModel | undefined;
59
+ /**
60
+ * Provider id for credential requests: always the **resolved** model's provider.
61
+ * Optional `binding.provider` is only a hint when no model resolved yet.
62
+ */
63
+ export declare function useCaseCredentialProviderId(resolved: ResolvedUseCaseModel | undefined, binding?: Pick<UseCaseModelBinding, "provider">): string | undefined;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Resolve the model for a non-session LLM job.
3
+ *
4
+ * Precedence:
5
+ * 1. `configured` → `source: "configured"`
6
+ * 2. Else `sessionModel` when `requireExplicitModel` is not set → `source: "session"`
7
+ * 3. Else `undefined` (caller skips / throws per package policy)
8
+ *
9
+ * O(1); no network. Does not mutate session history.
10
+ */
11
+ export function resolveUseCaseModel(input) {
12
+ const { providerOptions, thinkingLevel } = input;
13
+ if (input.configured) {
14
+ return {
15
+ model: input.configured,
16
+ source: "configured",
17
+ providerOptions,
18
+ thinkingLevel,
19
+ };
20
+ }
21
+ if (input.requireExplicitModel)
22
+ return undefined;
23
+ if (input.sessionModel) {
24
+ return {
25
+ model: input.sessionModel,
26
+ source: "session",
27
+ providerOptions,
28
+ thinkingLevel,
29
+ };
30
+ }
31
+ return undefined;
32
+ }
33
+ /**
34
+ * Resolve from a {@link UseCaseModelBinding} plus optional session fallback.
35
+ */
36
+ export function resolveUseCaseModelBinding(binding, sessionModel) {
37
+ return resolveUseCaseModel({
38
+ configured: binding?.model,
39
+ sessionModel,
40
+ requireExplicitModel: binding?.requireExplicitModel,
41
+ providerOptions: binding?.providerOptions,
42
+ thinkingLevel: binding?.thinkingLevel,
43
+ });
44
+ }
45
+ /**
46
+ * Provider id for credential requests: always the **resolved** model's provider.
47
+ * Optional `binding.provider` is only a hint when no model resolved yet.
48
+ */
49
+ export function useCaseCredentialProviderId(resolved, binding) {
50
+ return resolved?.model.provider ?? binding?.provider;
51
+ }
52
+ //# sourceMappingURL=use-case-model.js.map