@frockbot/kernel-contracts 0.3.2 → 0.3.4
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/package.json +1 -1
- package/src/superseded-history.test.ts +145 -0
- package/src/tool-execution.ts +21 -1
package/package.json
CHANGED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Turn the User's next message replaced looks like to the model request
|
|
3
|
+
* that follows it.
|
|
4
|
+
*
|
|
5
|
+
* The whole answer is in the durable log: `deriveMessages` walks the events and
|
|
6
|
+
* reads nothing else, so a superseded Turn contributes exactly what it made
|
|
7
|
+
* durable — what it said, and what its tools actually returned. There is no
|
|
8
|
+
* branch for supersede here, and that is the point: the next request is
|
|
9
|
+
* reconstructed from events, so a Turn that ended early is a Turn with fewer
|
|
10
|
+
* events, not a Turn with a special case.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { Session } from "./session.js";
|
|
14
|
+
import type { SessionEvent } from "./types.js";
|
|
15
|
+
|
|
16
|
+
const timestamp = "2026-09-03T00:00:00.000Z";
|
|
17
|
+
|
|
18
|
+
function session(): Session {
|
|
19
|
+
return new Session("user-1:primary", () => {});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A Turn that sent a line, completed one tool call, and had a second still in
|
|
24
|
+
* flight when the User's next message arrived.
|
|
25
|
+
*/
|
|
26
|
+
function supersededTurn(target: Session): void {
|
|
27
|
+
target.appendBatch([
|
|
28
|
+
{ type: "turn/start", turn: 1 },
|
|
29
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
30
|
+
{ type: "user/message", turn: 1, step: 1, messageId: "m-1", text: "go" },
|
|
31
|
+
{
|
|
32
|
+
type: "assistant/message",
|
|
33
|
+
turn: 1,
|
|
34
|
+
step: 1,
|
|
35
|
+
requestId: "request-1",
|
|
36
|
+
text: "starting",
|
|
37
|
+
toolCalls: [
|
|
38
|
+
{ id: "call-a", name: "read", input: {} },
|
|
39
|
+
{ id: "call-b", name: "write", input: {} },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: "tool/call",
|
|
44
|
+
turn: 1,
|
|
45
|
+
step: 1,
|
|
46
|
+
occurrenceId: "tool:1:1:0",
|
|
47
|
+
name: "read",
|
|
48
|
+
input: {},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: "tool/result",
|
|
52
|
+
turn: 1,
|
|
53
|
+
step: 1,
|
|
54
|
+
occurrenceId: "tool:1:1:0",
|
|
55
|
+
name: "read",
|
|
56
|
+
content: "the notes say hello",
|
|
57
|
+
isError: false,
|
|
58
|
+
status: "completed",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
type: "tool/call",
|
|
62
|
+
turn: 1,
|
|
63
|
+
step: 1,
|
|
64
|
+
occurrenceId: "tool:1:1:1",
|
|
65
|
+
name: "write",
|
|
66
|
+
input: {},
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("the next Turn's request derives from what the superseded Turn recorded", () => {
|
|
72
|
+
test("its sends and completed tool results are visible; the in-flight one is not claimed", () => {
|
|
73
|
+
const target = session();
|
|
74
|
+
supersededTurn(target);
|
|
75
|
+
// The interrupt: every unresolved effect is closed as interrupted, and the
|
|
76
|
+
// Turn is ended. This is the same repair a Stop makes.
|
|
77
|
+
target.reconcileInterrupted();
|
|
78
|
+
|
|
79
|
+
const messages = target.deriveMessages();
|
|
80
|
+
|
|
81
|
+
expect(messages).toEqual([
|
|
82
|
+
{ role: "user", content: "go" },
|
|
83
|
+
{
|
|
84
|
+
role: "assistant",
|
|
85
|
+
content: "starting",
|
|
86
|
+
toolCalls: [
|
|
87
|
+
{ id: "call-a", name: "read", input: {} },
|
|
88
|
+
{ id: "call-b", name: "write", input: {} },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
role: "tool",
|
|
93
|
+
callId: "call-a",
|
|
94
|
+
name: "read",
|
|
95
|
+
content: "the notes say hello",
|
|
96
|
+
isError: false,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
role: "tool",
|
|
100
|
+
callId: "call-b",
|
|
101
|
+
name: "write",
|
|
102
|
+
content: "Interrupted before a durable result was recorded.",
|
|
103
|
+
isError: true,
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
// The Turn ended, and the log says so. Nothing was invented for the effect
|
|
107
|
+
// that never returned: it is present, and it is an error.
|
|
108
|
+
expect(target.events.at(-1)).toMatchObject({
|
|
109
|
+
type: "turn/end",
|
|
110
|
+
outcome: "interrupted",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the new Turn's own message is simply the next one in the log", () => {
|
|
115
|
+
const target = session();
|
|
116
|
+
supersededTurn(target);
|
|
117
|
+
target.reconcileInterrupted();
|
|
118
|
+
target.appendBatch([
|
|
119
|
+
{ type: "turn/start", turn: 2 },
|
|
120
|
+
{ type: "step/start", turn: 2, step: 1 },
|
|
121
|
+
{
|
|
122
|
+
type: "user/message",
|
|
123
|
+
turn: 2,
|
|
124
|
+
step: 1,
|
|
125
|
+
messageId: "m-2",
|
|
126
|
+
text: "actually, do this instead",
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const messages = target.deriveMessages();
|
|
131
|
+
|
|
132
|
+
expect(messages.at(-1)).toEqual({
|
|
133
|
+
role: "user",
|
|
134
|
+
content: "actually, do this instead",
|
|
135
|
+
});
|
|
136
|
+
// Deterministic: the same events derive the same request every time.
|
|
137
|
+
expect(target.deriveMessages()).toEqual(messages);
|
|
138
|
+
const replayed = new Session(
|
|
139
|
+
"user-1:primary",
|
|
140
|
+
() => {},
|
|
141
|
+
target.events as readonly SessionEvent[],
|
|
142
|
+
);
|
|
143
|
+
expect(replayed.deriveMessages()).toEqual(messages);
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/tool-execution.ts
CHANGED
|
@@ -125,6 +125,12 @@ export type ToolEffectReconciliation =
|
|
|
125
125
|
| { status: "unavailable"; reason: string };
|
|
126
126
|
|
|
127
127
|
export interface ToolDefinition extends ToolSchema {
|
|
128
|
+
/**
|
|
129
|
+
* A dynamic Tool Namespace. Absent means the tool is native and its schema
|
|
130
|
+
* is offered to the model directly; present means the schema is disclosed
|
|
131
|
+
* and the tool is invoked only through the registry's two meta-tools.
|
|
132
|
+
*/
|
|
133
|
+
namespace?: string;
|
|
128
134
|
idempotent?: boolean;
|
|
129
135
|
/** The turn types this tool is offered on. Absent means all of them. */
|
|
130
136
|
admission?: TurnAdmissionV1;
|
|
@@ -185,10 +191,24 @@ export interface ToolRegistrationOptions {
|
|
|
185
191
|
subagentRoleCeiling?: readonly string[];
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
export interface ToolNamespaceRegistration {
|
|
195
|
+
name: string;
|
|
196
|
+
description?: string;
|
|
197
|
+
status?: "ready" | "needsAuth" | "error";
|
|
198
|
+
/** External namespaces require call metadata before an effect is admitted. */
|
|
199
|
+
external?: boolean;
|
|
200
|
+
/** Namespace-level instructions rendered in the dynamic catalog prompt. */
|
|
201
|
+
useInstructions?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
188
204
|
/** Contributing Packages register tool definitions through this surface. */
|
|
189
205
|
export interface ToolRegistration {
|
|
190
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Every registered identity, before turn/role admission trims the catalog.
|
|
208
|
+
* Native tools use their bare name; dynamic tools use `namespace/name`.
|
|
209
|
+
*/
|
|
191
210
|
registeredNames?(): string[];
|
|
211
|
+
registerNamespace(namespace: ToolNamespaceRegistration): () => void;
|
|
192
212
|
register(
|
|
193
213
|
definition: ToolDefinition,
|
|
194
214
|
options?: ToolRegistrationOptions,
|