@animalabs/agent-framework 0.21.1 → 0.22.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/README.md +43 -0
- package/dist/src/agent.d.ts +17 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +102 -42
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +22 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +64 -9
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +4 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +215 -38
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/framework.d.ts +250 -39
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1602 -371
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +21 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +40 -7
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +26 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +28 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +17 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +31 -7
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +1 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +85 -22
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +32 -4
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +46 -13
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +5 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +41 -11
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +4 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +45 -8
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +5 -2
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +25 -7
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +47 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
- package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
- package/dist/src/mcpl/tool-pattern-check.js +79 -0
- package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +15 -5
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +39 -0
- package/dist/src/mcpl/visible-content.d.ts.map +1 -0
- package/dist/src/mcpl/visible-content.js +54 -0
- package/dist/src/mcpl/visible-content.js.map +1 -0
- package/dist/src/module-registry.d.ts +81 -3
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +257 -16
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
- package/dist/src/modules/workspace/branch-intent.js +83 -0
- package/dist/src/modules/workspace/branch-intent.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.js +259 -0
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +84 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -0
- package/dist/src/modules/workspace/effects.js +368 -0
- package/dist/src/modules/workspace/effects.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +155 -17
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1059 -424
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +278 -0
- package/dist/src/modules/workspace/observe.d.ts.map +1 -0
- package/dist/src/modules/workspace/observe.js +805 -0
- package/dist/src/modules/workspace/observe.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts +224 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
- package/dist/src/modules/workspace/reconcile.js +901 -0
- package/dist/src/modules/workspace/reconcile.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +6 -59
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +6 -257
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +77 -10
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/record-journal.d.ts +127 -0
- package/dist/src/record-journal.d.ts.map +1 -0
- package/dist/src/record-journal.js +202 -0
- package/dist/src/record-journal.js.map +1 -0
- package/dist/src/shutdown.d.ts +13 -0
- package/dist/src/shutdown.d.ts.map +1 -0
- package/dist/src/shutdown.js +46 -0
- package/dist/src/shutdown.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +74 -0
- package/dist/src/silent-heartbeat.d.ts.map +1 -0
- package/dist/src/silent-heartbeat.js +77 -0
- package/dist/src/silent-heartbeat.js.map +1 -0
- package/dist/src/tool-presentation.d.ts +50 -0
- package/dist/src/tool-presentation.d.ts.map +1 -0
- package/dist/src/tool-presentation.js +235 -0
- package/dist/src/tool-presentation.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +125 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +310 -35
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +13 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +21 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +18 -2
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +4 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +12 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +14 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +73 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/batched-wake-locus.test.d.ts +2 -0
- package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
- package/dist/test/batched-wake-locus.test.js +166 -0
- package/dist/test/batched-wake-locus.test.js.map +1 -0
- package/dist/test/code-execution.test.js +386 -2
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/component-defaults.test.d.ts +2 -0
- package/dist/test/component-defaults.test.d.ts.map +1 -0
- package/dist/test/component-defaults.test.js +54 -0
- package/dist/test/component-defaults.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +57 -1
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.js +3 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate.test.js +118 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +977 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
- package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
- package/dist/test/helpers/pytc-stop-rows.js +143 -0
- package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +19 -6
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
- package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
- package/dist/test/mcpl-config-diagnostics.test.js +449 -0
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
- package/dist/test/mcpl-empty-content.test.d.ts +2 -0
- package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
- package/dist/test/mcpl-empty-content.test.js +368 -0
- package/dist/test/mcpl-empty-content.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.js +41 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/record-journal.test.d.ts +6 -0
- package/dist/test/record-journal.test.d.ts.map +1 -0
- package/dist/test/record-journal.test.js +269 -0
- package/dist/test/record-journal.test.js.map +1 -0
- package/dist/test/save-recent-image.test.js +102 -0
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.d.ts +2 -0
- package/dist/test/shutdown-errors.test.d.ts.map +1 -0
- package/dist/test/shutdown-errors.test.js +662 -0
- package/dist/test/shutdown-errors.test.js.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.js +451 -0
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.js +49 -38
- package/dist/test/silent-heartbeat.test.js.map +1 -1
- package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
- package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +672 -0
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
- package/dist/test/tool-batch-hook.test.d.ts +8 -0
- package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
- package/dist/test/tool-batch-hook.test.js +275 -0
- package/dist/test/tool-batch-hook.test.js.map +1 -0
- package/dist/test/tool-presentation.test.d.ts +2 -0
- package/dist/test/tool-presentation.test.d.ts.map +1 -0
- package/dist/test/tool-presentation.test.js +268 -0
- package/dist/test/tool-presentation.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +2330 -18
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +2 -1
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +35 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
- package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
- package/dist/test/unconvertible-tool-result.test.js +358 -0
- package/dist/test/unconvertible-tool-result.test.js.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.js +252 -0
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
- package/dist/test/workspace-three-way.test.d.ts +12 -0
- package/dist/test/workspace-three-way.test.d.ts.map +1 -0
- package/dist/test/workspace-three-way.test.js +3079 -0
- package/dist/test/workspace-three-way.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +120 -44
- package/src/code-execution/py-runner.ts +66 -9
- package/src/code-execution/runtime-py.ts +215 -38
- package/src/framework.ts +1661 -363
- package/src/gate/event-gate.ts +59 -7
- package/src/index.ts +11 -0
- package/src/mcpl/channel-registry.ts +24 -3
- package/src/mcpl/conversation-router.ts +34 -1
- package/src/mcpl/feature-set-manager.ts +36 -11
- package/src/mcpl/index.ts +2 -0
- package/src/mcpl/prose-stream-router.ts +79 -23
- package/src/mcpl/push-coalescer.ts +62 -13
- package/src/mcpl/push-handler.ts +41 -12
- package/src/mcpl/server-connection.ts +41 -10
- package/src/mcpl/server-registry.ts +24 -7
- package/src/mcpl/tool-lifecycle.ts +77 -2
- package/src/mcpl/tool-pattern-check.ts +106 -0
- package/src/mcpl/types.ts +15 -5
- package/src/mcpl/visible-content.ts +57 -0
- package/src/module-registry.ts +252 -16
- package/src/modules/workspace/branch-intent.ts +123 -0
- package/src/modules/workspace/disk-agreement.ts +325 -0
- package/src/modules/workspace/effects.ts +362 -0
- package/src/modules/workspace/index.ts +1058 -446
- package/src/modules/workspace/observe.ts +839 -0
- package/src/modules/workspace/reconcile.ts +1136 -0
- package/src/modules/workspace/sync.ts +6 -336
- package/src/modules/workspace/types.ts +75 -10
- package/src/record-journal.ts +246 -0
- package/src/shutdown.ts +49 -0
- package/src/silent-heartbeat.ts +141 -0
- package/src/tool-presentation.ts +188 -0
- package/src/tool-result-guard.ts +335 -33
- package/src/tool-result-history.ts +25 -1
- package/src/types/agent.ts +18 -2
- package/src/types/events.ts +4 -0
- package/src/types/framework.ts +12 -0
- package/src/types/module.ts +15 -0
- package/src/types/trace.ts +80 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An append-only journal of typed Chronicle records that does not follow
|
|
3
|
+
* branch switches.
|
|
4
|
+
*
|
|
5
|
+
* Chronicle's branch state — snapshot, tree and log states — rewinds with a
|
|
6
|
+
* historical rollback (createBranchAt + switchBranch). Typed records do not:
|
|
7
|
+
* `getRecordIdsByType` enumerates them from every branch, in global order, and
|
|
8
|
+
* they survive `deleteBranch`, reopen and a hard kill. A journal is for state
|
|
9
|
+
* that must not rewind with the conversation but should live and die with its
|
|
10
|
+
* store: what a workspace's disk last agreed with, what reached a recipient,
|
|
11
|
+
* what became of a suppressed draft.
|
|
12
|
+
*
|
|
13
|
+
* Durability. A record append reaches the OS before `append` returns, so it
|
|
14
|
+
* survives a killed process, while branch-state commits are buffered until
|
|
15
|
+
* `store.sync()`: a record appended after a state commit can outlive that
|
|
16
|
+
* commit. Two barriers, each named where it's needed and neither implied by
|
|
17
|
+
* the `{id, sequence}` an append returns:
|
|
18
|
+
* - `afterCommittedState: true` syncs BEFORE appending — for an entry that
|
|
19
|
+
* asserts state committed just before it, so the entry can never survive
|
|
20
|
+
* without that state;
|
|
21
|
+
* - `durable: true` syncs AFTER appending — for an entry that must be on
|
|
22
|
+
* stable storage before the caller acts on it (an attempt before its
|
|
23
|
+
* outbound request, an intent before its disk effect).
|
|
24
|
+
*
|
|
25
|
+
* Checkpoints bound the replay at open: `checkpoint(snapshot)` records the
|
|
26
|
+
* caller's reduced state together with the last entry it covers, and `load()`
|
|
27
|
+
* returns the latest checkpoint's snapshot plus only the entries appended
|
|
28
|
+
* after it. A journal that never checkpoints replays everything. What entries
|
|
29
|
+
* mean, how they reduce, and when to checkpoint stay with the caller.
|
|
30
|
+
*
|
|
31
|
+
* One writer per record type: a checkpoint covers every entry up to the last
|
|
32
|
+
* one THIS journal loaded or appended, so entries another instance appended
|
|
33
|
+
* to the same type in between would be covered without being reduced.
|
|
34
|
+
*
|
|
35
|
+
* An ambiguous write must be resolved before the journal writes again. Once
|
|
36
|
+
* `append` or `checkpoint` has handed a record to the store, a failure leaves
|
|
37
|
+
* a record the caller didn't reduce, or may leave one, and a later checkpoint
|
|
38
|
+
* would cover it without its ever entering the reducer. There are two kinds:
|
|
39
|
+
* - The store's append itself throws. Its record may still be in the store's
|
|
40
|
+
* log, outside this process's view of the store: the store writes a
|
|
41
|
+
* record to its log, syncs the log every so often, and only then indexes
|
|
42
|
+
* the record, which is what `getRecordIdsByType` reads. So a failed sync
|
|
43
|
+
* leaves a record that reopening the store will find and no `load()` on
|
|
44
|
+
* this store can. Nothing but a reopen rereads the log, so from then on
|
|
45
|
+
* every `append` and `checkpoint` throws, for the life of the journal. A
|
|
46
|
+
* journal on the store reopened, as at the next start, loads that record
|
|
47
|
+
* with its tail for the caller to reduce.
|
|
48
|
+
* - A failure after the append returned: a `durable` barrier, or reading
|
|
49
|
+
* the id it returned. The record is in this process's view, so every
|
|
50
|
+
* further `append` and `checkpoint` throws until `load()` has replayed the
|
|
51
|
+
* tail, that record included, for the caller to reduce.
|
|
52
|
+
* A failure BEFORE the record reaches the store (the `afterCommittedState`
|
|
53
|
+
* sync) wrote nothing and leaves the journal usable.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import type { JsStore } from '@animalabs/chronicle';
|
|
57
|
+
|
|
58
|
+
/** Where an appended record landed. Not a durability receipt — see the barriers. */
|
|
59
|
+
export interface JournalRecordRef {
|
|
60
|
+
/** Chronicle record id: global, increasing in append order. */
|
|
61
|
+
id: string;
|
|
62
|
+
/** The record's sequence on the branch current when it was appended. */
|
|
63
|
+
sequence: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface JournalEntry<Entry> extends JournalRecordRef {
|
|
67
|
+
entry: Entry;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface JournalLoad<Entry, Snapshot> {
|
|
71
|
+
/** The latest checkpoint's snapshot, or null when none was written. */
|
|
72
|
+
snapshot: Snapshot | null;
|
|
73
|
+
/** Every entry appended after that checkpoint, in append order. */
|
|
74
|
+
entries: Array<JournalEntry<Entry>>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AppendOptions {
|
|
78
|
+
/** `store.sync()` before appending: the entry asserts state committed just before it. */
|
|
79
|
+
afterCommittedState?: boolean;
|
|
80
|
+
/** `store.sync()` after appending: the entry must be on stable storage before the caller acts. */
|
|
81
|
+
durable?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface CheckpointPayload<Snapshot> {
|
|
85
|
+
/** Id of the last entry the snapshot covers ("0" when it covers none). */
|
|
86
|
+
through: string;
|
|
87
|
+
snapshot: Snapshot;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const asError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err)));
|
|
91
|
+
|
|
92
|
+
/** Record ids are decimal strings from one store-wide counter. */
|
|
93
|
+
function idValue(id: string): bigint {
|
|
94
|
+
if (!/^\d+$/.test(id)) throw new Error(`RecordJournal: unexpected chronicle record id "${id}"`);
|
|
95
|
+
return BigInt(id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function decode<T>(store: JsStore, id: string): { value: T; sequence: number } {
|
|
99
|
+
const record = store.getRecord(id);
|
|
100
|
+
if (!record) throw new Error(`RecordJournal: record ${id} is listed but cannot be read`);
|
|
101
|
+
return { value: JSON.parse(Buffer.from(record.payload).toString('utf-8')) as T, sequence: record.sequence };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class RecordJournal<Entry, Snapshot = never> {
|
|
105
|
+
private readonly store: JsStore;
|
|
106
|
+
/** Record type of the entries. */
|
|
107
|
+
readonly type: string;
|
|
108
|
+
/** Record type of the checkpoints. */
|
|
109
|
+
readonly checkpointType: string;
|
|
110
|
+
/** Highest entry id this journal has loaded or appended (0 when none). */
|
|
111
|
+
private lastEntryId = 0n;
|
|
112
|
+
private sinceCheckpoint = 0;
|
|
113
|
+
/** Set by a failure after the store's append returned; cleared by load(). */
|
|
114
|
+
private unreconciled: Error | null = null;
|
|
115
|
+
/** Set when the store's append itself threw: its record may be in the log
|
|
116
|
+
* but not in this process's view of the store. Never cleared. */
|
|
117
|
+
private unreadable: Error | null = null;
|
|
118
|
+
|
|
119
|
+
constructor(store: JsStore, opts: { type: string; checkpointType?: string }) {
|
|
120
|
+
if (!opts.type) throw new Error('RecordJournal: a record type is required');
|
|
121
|
+
this.store = store;
|
|
122
|
+
this.type = opts.type;
|
|
123
|
+
this.checkpointType = opts.checkpointType ?? `${opts.type}/checkpoint`;
|
|
124
|
+
if (this.checkpointType === this.type) {
|
|
125
|
+
throw new Error('RecordJournal: entries and checkpoints need distinct record types');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Entries appended since the last checkpoint (or since load, when there was none). */
|
|
130
|
+
get entriesSinceCheckpoint(): number {
|
|
131
|
+
return this.sinceCheckpoint;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The latest checkpoint and every entry after it, whichever branch each was
|
|
136
|
+
* appended on. The caller rebuilds its reduced state from exactly these —
|
|
137
|
+
* at open, and to reconcile after an ambiguous write. Also positions the
|
|
138
|
+
* journal, so later checkpoints cover what was loaded.
|
|
139
|
+
*/
|
|
140
|
+
load(): JournalLoad<Entry, Snapshot> {
|
|
141
|
+
let snapshot: Snapshot | null = null;
|
|
142
|
+
let through = 0n;
|
|
143
|
+
let latestCheckpoint: bigint | null = null;
|
|
144
|
+
let latestCheckpointId: string | null = null;
|
|
145
|
+
for (const id of this.store.getRecordIdsByType(this.checkpointType)) {
|
|
146
|
+
const value = idValue(id);
|
|
147
|
+
if (latestCheckpoint === null || value > latestCheckpoint) {
|
|
148
|
+
latestCheckpoint = value;
|
|
149
|
+
latestCheckpointId = id;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (latestCheckpointId !== null) {
|
|
153
|
+
const payload = decode<CheckpointPayload<Snapshot>>(this.store, latestCheckpointId).value;
|
|
154
|
+
snapshot = payload.snapshot;
|
|
155
|
+
through = idValue(payload.through);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tail = this.store.getRecordIdsByType(this.type)
|
|
159
|
+
.map((id) => ({ id, value: idValue(id) }))
|
|
160
|
+
.filter(({ value }) => value > through)
|
|
161
|
+
.sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0));
|
|
162
|
+
const entries = tail.map(({ id }) => {
|
|
163
|
+
const { value, sequence } = decode<Entry>(this.store, id);
|
|
164
|
+
return { id, sequence, entry: value };
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
this.lastEntryId = tail.length > 0 ? tail[tail.length - 1]!.value : through;
|
|
168
|
+
this.sinceCheckpoint = entries.length;
|
|
169
|
+
this.unreconciled = null;
|
|
170
|
+
return { snapshot, entries };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** True after a failure that load() can resolve, until it has replayed the
|
|
174
|
+
* tail. An append that threw inside the store is never resolved here: the
|
|
175
|
+
* journal stays unwritable until a journal on the reopened store loads it. */
|
|
176
|
+
get needsReconcile(): boolean {
|
|
177
|
+
return this.unreconciled !== null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** True once an append threw inside the store, for the rest of the
|
|
181
|
+
* journal's life. What `load()` returns may then lack that append's record,
|
|
182
|
+
* so a caller that decides from the journal's contents can't trust them
|
|
183
|
+
* until a journal on the reopened store has loaded it. */
|
|
184
|
+
get needsReopen(): boolean {
|
|
185
|
+
return this.unreadable !== null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Append one entry. The returned ref says where it landed, not that it's durable. */
|
|
189
|
+
append(entry: Entry, opts: AppendOptions = {}): JournalRecordRef {
|
|
190
|
+
this.assertReconciled();
|
|
191
|
+
if (opts.afterCommittedState) this.store.sync();
|
|
192
|
+
return this.write(this.type, entry, opts.durable === true, (ref) => {
|
|
193
|
+
const value = idValue(ref.id);
|
|
194
|
+
if (value > this.lastEntryId) this.lastEntryId = value;
|
|
195
|
+
this.sinceCheckpoint++;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Record the caller's reduced state as covering every entry this journal has
|
|
201
|
+
* loaded or appended. Like an entry, it's durable only with `durable`.
|
|
202
|
+
*/
|
|
203
|
+
checkpoint(snapshot: Snapshot, opts: { durable?: boolean } = {}): JournalRecordRef {
|
|
204
|
+
this.assertReconciled();
|
|
205
|
+
const payload: CheckpointPayload<Snapshot> = { through: this.lastEntryId.toString(), snapshot };
|
|
206
|
+
return this.write(this.checkpointType, payload, opts.durable === true, () => {
|
|
207
|
+
this.sinceCheckpoint = 0;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Hand one record to the store; any failure from here on is ambiguous. */
|
|
212
|
+
private write(type: string, payload: unknown, durable: boolean, onWritten: (ref: JournalRecordRef) => void): JournalRecordRef {
|
|
213
|
+
let record: { id: string; sequence: number };
|
|
214
|
+
try {
|
|
215
|
+
record = this.store.appendJson(type, payload);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
this.unreadable = asError(err);
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const ref = { id: record.id, sequence: record.sequence };
|
|
222
|
+
onWritten(ref);
|
|
223
|
+
if (durable) this.store.sync();
|
|
224
|
+
return ref;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
this.unreconciled = asError(err);
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private assertReconciled(): void {
|
|
232
|
+
if (this.unreadable) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`RecordJournal(${this.type}): an earlier append failed inside the store ` +
|
|
235
|
+
`(${this.unreadable.message}), and its record may be in the store's log where this process can't ` +
|
|
236
|
+
`read it back; nothing more is written until the store is reopened, as at the next start`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (this.unreconciled) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`RecordJournal(${this.type}): an earlier write failed after reaching the store ` +
|
|
242
|
+
`(${this.unreconciled.message}); load() and reduce its tail before writing again`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
package/src/shutdown.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** No implicit deadline: the caller owns the module/child shutdown contract. */
|
|
2
|
+
export const DEFAULT_SHUTDOWN_TIMEOUT_MS: number | undefined = undefined;
|
|
3
|
+
|
|
4
|
+
export function validateShutdownTimeout(timeoutMs: number | undefined): void {
|
|
5
|
+
if (timeoutMs === undefined) return;
|
|
6
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) {
|
|
7
|
+
throw new RangeError('Shutdown timeout must be an integer from 1 to 2147483647 ms');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ShutdownTimeoutError extends AggregateError {
|
|
12
|
+
constructor(readonly pending: string[], timeoutMs: number, failures: unknown[]) {
|
|
13
|
+
super([...failures, new Error(`Shutdown timed out after ${timeoutMs} ms; pending: ${pending.join(', ')}`)],
|
|
14
|
+
`Shutdown timed out after ${timeoutMs} ms; pending: ${pending.join(', ')}` +
|
|
15
|
+
(failures.length ? `; failures: ${failures.map(shutdownErrorMessage).join('; ')}` : ''));
|
|
16
|
+
this.name = 'ShutdownTimeoutError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function shutdownErrorMessage(error: unknown): string {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function waitForShutdown(
|
|
25
|
+
attempts: Array<{ label: string; promise: Promise<void> }>, timeoutMs: number | undefined,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
validateShutdownTimeout(timeoutMs);
|
|
28
|
+
const pending = new Set(attempts.map((_, index) => index));
|
|
29
|
+
const failures = new Map<number, unknown>();
|
|
30
|
+
const settled = Promise.all(attempts.map(({ promise }, index) => promise.then(
|
|
31
|
+
() => { pending.delete(index); },
|
|
32
|
+
error => { failures.set(index, error); pending.delete(index); },
|
|
33
|
+
)));
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
try {
|
|
36
|
+
if (timeoutMs === undefined) await settled;
|
|
37
|
+
else await Promise.race([settled, new Promise<void>((_, reject) => {
|
|
38
|
+
timer = setTimeout(() => reject(new ShutdownTimeoutError(
|
|
39
|
+
[...pending].map(index => attempts[index]!.label), timeoutMs, [...failures.values()],
|
|
40
|
+
)), timeoutMs);
|
|
41
|
+
})]);
|
|
42
|
+
} finally {
|
|
43
|
+
if (timer) clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
const errors = [...failures.values()];
|
|
46
|
+
if (errors.length === 1) throw errors[0];
|
|
47
|
+
if (errors.length > 1) throw new AggregateError(errors,
|
|
48
|
+
`Shutdown failed: ${errors.map(shutdownErrorMessage).join('; ')}`);
|
|
49
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored-row handling for silent heartbeat ticks.
|
|
3
|
+
*
|
|
4
|
+
* A silent tick stores no prompt row (#216): its reply is written straight
|
|
5
|
+
* after whatever the window held before it — often the previous assistant
|
|
6
|
+
* message, since a suppressed tick leaves no delivery receipt behind. The
|
|
7
|
+
* wire formatter merges consecutive same-role messages, so without a turn
|
|
8
|
+
* between them the tick would read as an unprompted continuation of the
|
|
9
|
+
* earlier response and one assistant message would carry signed thinking
|
|
10
|
+
* from two separate responses (a provider 400 when it is the latest
|
|
11
|
+
* assistant message; see `Agent.addAssistantResponse`).
|
|
12
|
+
*
|
|
13
|
+
* Every row a tick writes is therefore stamped with `metadata.silentHeartbeat`
|
|
14
|
+
* and, at request-build time only, a terse user turn is rendered immediately
|
|
15
|
+
* before the first of the tick's rows that survives compilation. The
|
|
16
|
+
* separator is never stored and its position is a function of the stamped
|
|
17
|
+
* rows alone, so every compile renders it in the same place (the tick's own
|
|
18
|
+
* request ends on the same turn), which keeps the prompt-cache prefix
|
|
19
|
+
* stable — unlike a per-compile injection that re-anchors to the newest user
|
|
20
|
+
* message.
|
|
21
|
+
*
|
|
22
|
+
* Known limit: compiled messages carry no store provenance, so a stored row
|
|
23
|
+
* is recognised only by structural identity the provider made unique
|
|
24
|
+
* (tool_use ids, thinking signatures). A tick reply with neither — unsigned
|
|
25
|
+
* and tool-free — gets no separator and still merges as before. Without
|
|
26
|
+
* signed thinking that merge carries no provider-rejection risk; it is only
|
|
27
|
+
* a misreading. Reply text is deliberately never used as identity: identical
|
|
28
|
+
* replies ("all quiet") would collide, and an ordinary reply with the same
|
|
29
|
+
* text could take a tick's separator and rewrite cached history. Exact
|
|
30
|
+
* matching for every row would need the compile result to expose each
|
|
31
|
+
* message's source rows.
|
|
32
|
+
*/
|
|
33
|
+
import type { ContentBlock, NormalizedMessage } from '@animalabs/membrane';
|
|
34
|
+
|
|
35
|
+
/** Request-only separator text. Constant on purpose: it carries no time or
|
|
36
|
+
* id, so it is byte-identical on every compile. */
|
|
37
|
+
export const SILENT_HEARTBEAT_SEPARATOR = '[heartbeat tick]';
|
|
38
|
+
|
|
39
|
+
/** Identity of one authenticated silent heartbeat tick. */
|
|
40
|
+
export interface SilentHeartbeatTick {
|
|
41
|
+
/** The MCPL push event that woke the tick. A broadcast tick reaches every
|
|
42
|
+
* resident with the same id. */
|
|
43
|
+
eventId: string;
|
|
44
|
+
/** The MCPL server that sent it. */
|
|
45
|
+
serverId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `metadata.silentHeartbeat` on every row a tick's turn writes. Residents
|
|
49
|
+
* share one message slot, so the stamp names the agent whose turn wrote it
|
|
50
|
+
* (tool_result rows are stored as `user`). */
|
|
51
|
+
export interface SilentHeartbeatStamp extends SilentHeartbeatTick {
|
|
52
|
+
agentName: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The tick stamp on a stored row's metadata, if any. */
|
|
56
|
+
export function silentHeartbeatOf(metadata: unknown): SilentHeartbeatStamp | undefined {
|
|
57
|
+
const stamp = (metadata as { silentHeartbeat?: unknown } | undefined)?.silentHeartbeat;
|
|
58
|
+
if (!stamp || typeof stamp !== 'object') return undefined;
|
|
59
|
+
const { eventId, serverId, agentName } = stamp as Record<string, unknown>;
|
|
60
|
+
return typeof eventId === 'string' && typeof serverId === 'string' && typeof agentName === 'string'
|
|
61
|
+
? { eventId, serverId, agentName }
|
|
62
|
+
: undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function silentHeartbeatSeparatorTurn(): NormalizedMessage {
|
|
66
|
+
return { participant: 'user', content: [{ type: 'text', text: SILENT_HEARTBEAT_SEPARATOR }] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Structural identity of one stored response wherever a compile renders it:
|
|
71
|
+
* tool_use ids and thinking signatures, which the provider made unique and
|
|
72
|
+
* strategies keep verbatim (the same identity the tool-result guard relies
|
|
73
|
+
* on). Text is never read, so request-build sanitizing (blank text blocks
|
|
74
|
+
* stripped) cannot make a stored row and its compiled copy disagree.
|
|
75
|
+
*/
|
|
76
|
+
function identityKeys(content: ContentBlock[]): string[] {
|
|
77
|
+
const keys: string[] = [];
|
|
78
|
+
for (const block of content) {
|
|
79
|
+
if (block.type === 'tool_use' && typeof block.id === 'string' && block.id) keys.push(`tool_use:${block.id}`);
|
|
80
|
+
else if (block.type === 'thinking' && typeof block.signature === 'string' && block.signature) {
|
|
81
|
+
keys.push(`thinking:${block.signature}`);
|
|
82
|
+
} else if (block.type === 'redacted_thinking' && typeof block.data === 'string' && block.data) {
|
|
83
|
+
keys.push(`redacted:${block.data}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return keys;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface StoredRowLike {
|
|
90
|
+
participant: string;
|
|
91
|
+
content: ContentBlock[];
|
|
92
|
+
metadata?: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SilentTickIndex {
|
|
96
|
+
/** Identity key of every assistant row this agent stored during a tick → tick event id. */
|
|
97
|
+
rows: Map<string, string>;
|
|
98
|
+
/** Ticks this agent has already stored rows for (its own, not another resident's). */
|
|
99
|
+
started: Set<string>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Index the rows this agent stored during silent ticks. */
|
|
103
|
+
export function indexSilentTickRows(stored: readonly StoredRowLike[], agentName: string): SilentTickIndex {
|
|
104
|
+
const rows = new Map<string, string>();
|
|
105
|
+
const started = new Set<string>();
|
|
106
|
+
for (const row of stored) {
|
|
107
|
+
const stamp = silentHeartbeatOf(row.metadata);
|
|
108
|
+
if (!stamp || stamp.agentName !== agentName) continue;
|
|
109
|
+
started.add(stamp.eventId);
|
|
110
|
+
if (row.participant !== agentName) continue;
|
|
111
|
+
for (const key of identityKeys(row.content)) rows.set(key, stamp.eventId);
|
|
112
|
+
}
|
|
113
|
+
return { rows, started };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Insert the separator turn immediately before the first surviving row of
|
|
118
|
+
* each tick in a compiled window — the tick's first row when it is rendered,
|
|
119
|
+
* otherwise whichever later row a strategy kept. Rows folded into summaries
|
|
120
|
+
* simply do not match; ordinary messages pass through untouched.
|
|
121
|
+
*/
|
|
122
|
+
export function separateSilentHeartbeatTicks(
|
|
123
|
+
messages: NormalizedMessage[],
|
|
124
|
+
rows: ReadonlyMap<string, string>,
|
|
125
|
+
agentName: string,
|
|
126
|
+
): NormalizedMessage[] {
|
|
127
|
+
if (rows.size === 0) return messages;
|
|
128
|
+
const separated = new Set<string>();
|
|
129
|
+
const out: NormalizedMessage[] = [];
|
|
130
|
+
for (const message of messages) {
|
|
131
|
+
if (message.participant === agentName) {
|
|
132
|
+
const eventId = identityKeys(message.content).map((key) => rows.get(key)).find((id) => id !== undefined);
|
|
133
|
+
if (eventId !== undefined && !separated.has(eventId)) {
|
|
134
|
+
separated.add(eventId);
|
|
135
|
+
out.push(silentHeartbeatSeparatorTurn());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
out.push(message);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { fchmodSync, closeSync, fstatSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import type { ToolDefinition, ToolResult } from './types/events.js';
|
|
4
|
+
|
|
5
|
+
export interface ToolPresentationConfig {
|
|
6
|
+
/** Absolute path to shared editable JSON. Missing file means no overrides. */
|
|
7
|
+
path: string;
|
|
8
|
+
/** Reserved generated workspace path, e.g. board/tool-catalogue.md. */
|
|
9
|
+
cataloguePath: string;
|
|
10
|
+
/** Optional description-only profiles, bound to a registered component source. */
|
|
11
|
+
defaults?: { source: string; path: string }[];
|
|
12
|
+
}
|
|
13
|
+
export interface ToolOverride { description?: string; visible?: boolean }
|
|
14
|
+
export interface PresentationDocument { version: 1; tools: Record<string, ToolOverride> }
|
|
15
|
+
export interface PresentationSnapshot {
|
|
16
|
+
revision: string;
|
|
17
|
+
source: string;
|
|
18
|
+
cataloguePath: string;
|
|
19
|
+
diagnostics: string[];
|
|
20
|
+
available: ToolDefinition[];
|
|
21
|
+
advertised: ToolDefinition[];
|
|
22
|
+
entries: { name: string; source: string; visible: boolean; originalDescription: string; description: string; descriptionSource?: string; inputSchema: ToolDefinition['inputSchema'] }[];
|
|
23
|
+
}
|
|
24
|
+
const MAX_BYTES = 256 * 1024;
|
|
25
|
+
const protectedNames = new Set(['set_tool_visibility', 'set_tool_description', 'workspace--read']);
|
|
26
|
+
export const isPresentationTool = (name: string): boolean => name === 'set_tool_visibility' || name === 'set_tool_description';
|
|
27
|
+
const hash = (value: string): string => createHash('sha256').update(value).digest('hex');
|
|
28
|
+
|
|
29
|
+
export function parsePresentation(raw: string): PresentationDocument {
|
|
30
|
+
const doc = JSON.parse(raw);
|
|
31
|
+
if (!doc || doc.version !== 1 || !doc.tools || typeof doc.tools !== 'object' || Array.isArray(doc.tools)
|
|
32
|
+
|| Object.keys(doc).some(k => !['version', 'tools'].includes(k))) throw new Error('Expected {"version":1,"tools":{...}}');
|
|
33
|
+
for (const [name, value] of Object.entries(doc.tools)) {
|
|
34
|
+
if (!name || !value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid override: ${name}`);
|
|
35
|
+
const item = value as Record<string, unknown>;
|
|
36
|
+
if (Object.keys(item).some(k => !['description', 'visible'].includes(k))
|
|
37
|
+
|| ('description' in item && (typeof item.description !== 'string' || item.description.length > 32768))
|
|
38
|
+
|| ('visible' in item && typeof item.visible !== 'boolean')) throw new Error(`Invalid description/visibility for ${name}`);
|
|
39
|
+
if (protectedNames.has(name) && item.visible === false) throw new Error(`Cannot hide recovery tool ${name}`);
|
|
40
|
+
}
|
|
41
|
+
return doc;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function presentationTools(path: string): ToolDefinition[] {
|
|
45
|
+
const signpost = ` Read the generated catalogue with workspace--read {"path":${JSON.stringify(path)},"limit":140}. Hidden tools remain available; changes affect the next newly compiled request, not an in-flight stream.`;
|
|
46
|
+
return [
|
|
47
|
+
{name:'set_tool_visibility', description:'Show or hide a tool definition. Hiding does not revoke permission.' + signpost,
|
|
48
|
+
inputSchema:{type:'object',properties:{name:{type:'string'},visible:{type:'boolean'}},required:['name','visible'],additionalProperties:false}},
|
|
49
|
+
{name:'set_tool_description', description:'Set a personal tool description; null restores the configured component default, or the installed description when none is configured.' + signpost,
|
|
50
|
+
inputSchema:{type:'object',properties:{name:{type:'string'},description:{type:['string','null']}},required:['name','description'],additionalProperties:false}},
|
|
51
|
+
] as unknown as ToolDefinition[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Per-agent presentation; files are the sole persistent source, independent of editor identity. */
|
|
55
|
+
export class ToolPresentation {
|
|
56
|
+
constructor(readonly config: ToolPresentationConfig) {}
|
|
57
|
+
private read(path = this.config.path, allowMissing = true): string {
|
|
58
|
+
let fd: number;
|
|
59
|
+
try { fd = openSync(path, 'r'); }
|
|
60
|
+
catch (error) { if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return '{"version":1,"tools":{}}'; throw error; }
|
|
61
|
+
try {
|
|
62
|
+
const stat = fstatSync(fd);
|
|
63
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error('Presentation must be a regular file under 256 KiB');
|
|
64
|
+
const data = Buffer.alloc(MAX_BYTES + 1);
|
|
65
|
+
let size = 0, count = 0;
|
|
66
|
+
while (size < data.length && (count = readSync(fd, data, size, data.length - size, null)) > 0) size += count;
|
|
67
|
+
if (size > MAX_BYTES) throw new Error('Presentation exceeds 256 KiB');
|
|
68
|
+
return data.subarray(0,size).toString('utf8');
|
|
69
|
+
} finally { closeSync(fd); }
|
|
70
|
+
}
|
|
71
|
+
resolve(tools: ToolDefinition[], sources: ReadonlyMap<string, string> = new Map()): PresentationSnapshot {
|
|
72
|
+
let raw = '', doc: PresentationDocument = {version:1,tools:{}}, diagnostics: string[] = [];
|
|
73
|
+
try { raw = this.read(); doc = parsePresentation(raw); }
|
|
74
|
+
catch (error) { diagnostics.push(`Overrides not applied: ${String(error)}. Default visibility and component descriptions retained.`); }
|
|
75
|
+
const defaults = new Map<string, {description: string; path: string}>();
|
|
76
|
+
const configuredSources = new Set<string>();
|
|
77
|
+
for (const profile of this.config.defaults ?? []) {
|
|
78
|
+
if (configuredSources.has(profile.source)) {
|
|
79
|
+
diagnostics.push(`Duplicate defaults source ignored: ${profile.source}`); continue;
|
|
80
|
+
}
|
|
81
|
+
configuredSources.add(profile.source);
|
|
82
|
+
// Absent components contribute neither tools nor missing-file errors.
|
|
83
|
+
if (!tools.some(t => (sources.get(t.name) ?? 'Unattributed') === profile.source)) continue;
|
|
84
|
+
try {
|
|
85
|
+
const profileDoc = parsePresentation(this.read(profile.path, false));
|
|
86
|
+
if (Object.values(profileDoc.tools).some(item => Object.keys(item).some(k => k !== 'description')))
|
|
87
|
+
throw new Error('Component defaults may contain descriptions only; visibility belongs to the resident');
|
|
88
|
+
for (const tool of tools) {
|
|
89
|
+
if ((sources.get(tool.name) ?? 'Unattributed') !== profile.source) continue;
|
|
90
|
+
const item = Object.hasOwn(profileDoc.tools, tool.name) ? profileDoc.tools[tool.name] : undefined;
|
|
91
|
+
if (item?.description !== undefined) defaults.set(tool.name, {description:item.description,path:profile.path});
|
|
92
|
+
}
|
|
93
|
+
} catch (error) { diagnostics.push(`Component defaults not applied (${profile.source}): ${String(error)}`); }
|
|
94
|
+
}
|
|
95
|
+
const names = new Set(tools.map(t => t.name));
|
|
96
|
+
for (const name of Object.keys(doc.tools)) if (!names.has(name)) diagnostics.push(`Not currently available: ${name}`);
|
|
97
|
+
const entries = tools.map(tool => {
|
|
98
|
+
const override = Object.hasOwn(doc.tools, tool.name) ? doc.tools[tool.name] : undefined;
|
|
99
|
+
// Always retain a usable discovery signpost, even when its wording is overridden.
|
|
100
|
+
const componentDefault = defaults.get(tool.name);
|
|
101
|
+
let description = override?.description ?? componentDefault?.description ?? tool.description;
|
|
102
|
+
const descriptionSource = override?.description !== undefined ? this.config.path : componentDefault?.path ?? "installed component";
|
|
103
|
+
if (isPresentationTool(tool.name) && (override?.description !== undefined || componentDefault !== undefined))
|
|
104
|
+
description += `\nCatalogue: workspace--read {"path":${JSON.stringify(this.config.cataloguePath)},"limit":140}. Changes affect the next newly compiled request.`;
|
|
105
|
+
return {name:tool.name,source:sources.get(tool.name) ?? 'Unattributed',visible:override?.visible ?? true,originalDescription:tool.description,description,descriptionSource,inputSchema:structuredClone(tool.inputSchema)};
|
|
106
|
+
});
|
|
107
|
+
const available = tools.map((tool,i) => ({...structuredClone(tool),description:entries[i].description}));
|
|
108
|
+
return {revision:hash(JSON.stringify({raw,entries,diagnostics})),source:this.config.path,cataloguePath:this.config.cataloguePath,diagnostics,available,
|
|
109
|
+
advertised:available.filter((_,i)=>entries[i].visible),entries};
|
|
110
|
+
}
|
|
111
|
+
edit(tool: string, input: unknown, available: ToolDefinition[]): ToolResult {
|
|
112
|
+
const lock = this.config.path + '.lock';
|
|
113
|
+
let fd: number | undefined, tempFd: number | undefined, temp: string | undefined;
|
|
114
|
+
try {
|
|
115
|
+
if (!isPresentationTool(tool)) throw new Error('Unknown editing tool');
|
|
116
|
+
const value = input as Record<string, unknown>;
|
|
117
|
+
const field = tool === 'set_tool_visibility' ? 'visible' : 'description';
|
|
118
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.name !== 'string'
|
|
119
|
+
|| Object.keys(value).some(k=> !['name',field].includes(k)) || !Object.hasOwn(value,field)) throw new Error('Invalid editing arguments');
|
|
120
|
+
if (!available.some(t=>t.name===value.name)) throw new Error('Tool is not currently available to this agent');
|
|
121
|
+
if (field === 'visible' ? typeof value.visible !== 'boolean' : value.description !== null && typeof value.description !== 'string') throw new Error(`Invalid ${field}`);
|
|
122
|
+
fd = openSync(lock, 'wx', 0o600);
|
|
123
|
+
let mode = 0o600;
|
|
124
|
+
try { const stat = lstatSync(this.config.path); if (stat.isSymbolicLink()) throw new Error('Edit the target file directly; tool editing refuses symlinks'); mode = stat.mode & 0o777; }
|
|
125
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
126
|
+
const before = this.read(), doc = parsePresentation(before);
|
|
127
|
+
const old = Object.hasOwn(doc.tools,value.name) ? doc.tools[value.name] : {};
|
|
128
|
+
const updated = {...old};
|
|
129
|
+
if (field === 'description' && value.description === null) delete updated.description;
|
|
130
|
+
else Object.assign(updated,{[field]:value[field]});
|
|
131
|
+
Object.defineProperty(doc.tools,value.name,{value:updated,enumerable:true,writable:true,configurable:true});
|
|
132
|
+
const next = JSON.stringify(doc,null,2)+'\n';
|
|
133
|
+
parsePresentation(next);
|
|
134
|
+
if (Buffer.byteLength(next)>MAX_BYTES) throw new Error('Presentation exceeds 256 KiB');
|
|
135
|
+
temp = this.config.path + '.' + randomUUID() + '.tmp';
|
|
136
|
+
tempFd = openSync(temp,'wx',0o600);
|
|
137
|
+
writeFileSync(tempFd,next);
|
|
138
|
+
fchmodSync(tempFd,mode); // preserve permissions on the opened inode, never a replacement path
|
|
139
|
+
if (this.read() !== before) throw new Error('File changed concurrently; retry after reading it');
|
|
140
|
+
const opened = fstatSync(tempFd), named = lstatSync(temp);
|
|
141
|
+
if (!named.isFile() || named.dev !== opened.dev || named.ino !== opened.ino)
|
|
142
|
+
throw new Error('Temporary file changed concurrently; retry after checking the directory');
|
|
143
|
+
renameSync(temp,this.config.path); temp=undefined;
|
|
144
|
+
return {success:true,data:{name:value.name,[field]:value[field],effective:'next newly compiled request',catalogue:this.config.cataloguePath}};
|
|
145
|
+
} catch (error) { return {success:false,isError:true,error:String(error)}; }
|
|
146
|
+
finally { if(tempFd!==undefined) closeSync(tempFd); if(temp) try{unlinkSync(temp);}catch{} if(fd!==undefined){closeSync(fd);unlinkSync(lock);} }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function renderCatalogue(snapshot: PresentationSnapshot): string {
|
|
150
|
+
const groups = new Map<string, PresentationSnapshot['entries']>();
|
|
151
|
+
for (const entry of snapshot.entries) {
|
|
152
|
+
const group = groups.get(entry.source) ?? [];
|
|
153
|
+
group.push(entry); groups.set(entry.source, group);
|
|
154
|
+
}
|
|
155
|
+
const visible = snapshot.entries.filter(e => e.visible).length;
|
|
156
|
+
const lines: string[] = [
|
|
157
|
+
'# Tool catalogue — generated from currently available tools', '',
|
|
158
|
+
`Revision: ${snapshot.revision}`, '',
|
|
159
|
+
`${snapshot.entries.length} tools: ${visible} visible, ${snapshot.entries.length-visible} hidden.`,
|
|
160
|
+
'Visible = supplied in new requests. Hidden = stored away, still available.',
|
|
161
|
+
'These are your visibility choices, not fixed primary/secondary rankings.',
|
|
162
|
+
'Copy exact names from the index; similar-sounding names may not exist.',
|
|
163
|
+
'Show a hidden tool: set_tool_visibility {"name":"EXACT_NAME","visible":true}.',
|
|
164
|
+
`Read details: workspace--read {"path":${JSON.stringify(snapshot.cataloguePath)},"offset":LINE,"limit":LINES}.`,
|
|
165
|
+
'Line references below belong to this revision; reread the index after changes.', '',
|
|
166
|
+
];
|
|
167
|
+
for (const diagnostic of snapshot.diagnostics) lines.push('Diagnostic: '+diagnostic);
|
|
168
|
+
lines.push('## Index by source', '');
|
|
169
|
+
const index = new Map<string, number>();
|
|
170
|
+
for (const [source, entries] of groups) {
|
|
171
|
+
lines.push(`### ${source} (${entries.length})`);
|
|
172
|
+
for (const entry of entries) {index.set(entry.name,lines.length);lines.push('');}
|
|
173
|
+
lines.push('');
|
|
174
|
+
}
|
|
175
|
+
lines.push('## Full definitions', '');
|
|
176
|
+
for (const [source, entries] of groups) {
|
|
177
|
+
lines.push(`### ${source}`, '');
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
const start = lines.length + 1;
|
|
180
|
+
lines.push(`#### ${entry.name} (${entry.visible?'visible':'hidden'})`, '',
|
|
181
|
+
...entry.description.split('\n'), '', `Description from: ${entry.descriptionSource ?? 'installed component'}`, '', `Schema: ${JSON.stringify(entry.inputSchema)}`);
|
|
182
|
+
if(entry.description!==entry.originalDescription) lines.push('', 'Original description:', ...entry.originalDescription.split('\n'));
|
|
183
|
+
lines.push('');
|
|
184
|
+
lines[index.get(entry.name)!] = `- [${entry.visible?'visible':'hidden'}] ${entry.name} — details: offset ${start}, limit ${lines.length-start+1}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return lines.join('\n');
|
|
188
|
+
}
|