@frockbot/plugin-subagents 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/agent.test.ts +3 -4
- package/src/agent.ts +1 -1
- package/src/models.test.ts +22 -23
- package/src/models.ts +11 -11
- package/src/records.test.ts +30 -1
- package/src/records.ts +64 -9
- package/src/store.test.ts +40 -2
- package/src/store.ts +12 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@frockbot/kernel-agent-loop": "0.
|
|
31
|
-
"@frockbot/kernel-contracts": "0.
|
|
30
|
+
"@frockbot/kernel-agent-loop": "0.2.0",
|
|
31
|
+
"@frockbot/kernel-contracts": "0.2.0",
|
|
32
32
|
"cordis": "4.0.0-rc.8"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
package/src/agent.test.ts
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
import type { ToolExecutionContext } from "@frockbot/kernel-contracts";
|
|
24
24
|
|
|
25
25
|
const BINDING: TaskModelBindingV1 = {
|
|
26
|
-
assignmentId: "asg-1",
|
|
27
26
|
packageId: "provider-ollama-cloud",
|
|
28
27
|
capabilityId: "ollama-cloud-models",
|
|
29
28
|
connectionId: "conn-1",
|
|
@@ -55,7 +54,7 @@ function tool(
|
|
|
55
54
|
turnType,
|
|
56
55
|
models: () =>
|
|
57
56
|
subagentModelCatalogV1({
|
|
58
|
-
|
|
57
|
+
bindings: [BINDING],
|
|
59
58
|
defaultBinding: BINDING,
|
|
60
59
|
turnType,
|
|
61
60
|
}),
|
|
@@ -194,7 +193,7 @@ describe("the <available_subagent_models> section", () => {
|
|
|
194
193
|
const section = createSubagentModelsPromptSectionV1({
|
|
195
194
|
models: () =>
|
|
196
195
|
subagentModelCatalogV1({
|
|
197
|
-
|
|
196
|
+
bindings: [BINDING],
|
|
198
197
|
defaultBinding: BINDING,
|
|
199
198
|
turnType: "chat",
|
|
200
199
|
}),
|
|
@@ -210,7 +209,7 @@ describe("the <available_subagent_models> section", () => {
|
|
|
210
209
|
);
|
|
211
210
|
});
|
|
212
211
|
|
|
213
|
-
test("renders nothing for a Bot with no enabled model
|
|
212
|
+
test("renders nothing for a Bot with no enabled model binding", async () => {
|
|
214
213
|
const section = createSubagentModelsPromptSectionV1({ models: () => [] });
|
|
215
214
|
expect(
|
|
216
215
|
await section.render({
|
package/src/agent.ts
CHANGED
|
@@ -244,7 +244,7 @@ export interface SubagentsRuntimeHostV1 {
|
|
|
244
244
|
* into each step of its Turn and folds what it gets into that step's inputs.
|
|
245
245
|
*/
|
|
246
246
|
drainMessages?(): Promise<readonly PendingTaskMessageV1[]>;
|
|
247
|
-
/** The models this Turn may dispatch onto, resolved from enabled
|
|
247
|
+
/** The models this Turn may dispatch onto, resolved from enabled bindings. */
|
|
248
248
|
models(): readonly SubagentModelOptionV1[];
|
|
249
249
|
dispatch(
|
|
250
250
|
request: SubagentDispatchRequestV1,
|
package/src/models.test.ts
CHANGED
|
@@ -7,23 +7,22 @@ import {
|
|
|
7
7
|
} from "./models.js";
|
|
8
8
|
import type { TaskModelBindingV1 } from "./records.js";
|
|
9
9
|
|
|
10
|
-
function
|
|
10
|
+
function binding(
|
|
11
11
|
packageId: string,
|
|
12
12
|
providerModelId: string,
|
|
13
|
-
|
|
13
|
+
connectionId = `conn-${providerModelId}`,
|
|
14
14
|
): TaskModelBindingV1 {
|
|
15
15
|
return {
|
|
16
|
-
assignmentId,
|
|
17
16
|
packageId,
|
|
18
17
|
capabilityId: `${packageId}-models`,
|
|
19
|
-
connectionId
|
|
18
|
+
connectionId,
|
|
20
19
|
provider: packageId,
|
|
21
20
|
providerModelId,
|
|
22
21
|
};
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
const DEFAULT =
|
|
26
|
-
const SECOND =
|
|
24
|
+
const DEFAULT = binding("provider-ollama-cloud", "glm-5.3-flash:cloud");
|
|
25
|
+
const SECOND = binding("provider-foundation", "sand-automation");
|
|
27
26
|
|
|
28
27
|
describe("the slug", () => {
|
|
29
28
|
test("is the Package and the provider model, and nothing else", () => {
|
|
@@ -34,9 +33,9 @@ describe("the slug", () => {
|
|
|
34
33
|
});
|
|
35
34
|
|
|
36
35
|
describe("the catalog one Turn is offered", () => {
|
|
37
|
-
test("is built from the
|
|
36
|
+
test("is built from the User's enabled model bindings, default first", () => {
|
|
38
37
|
const catalog = subagentModelCatalogV1({
|
|
39
|
-
|
|
38
|
+
bindings: [SECOND, DEFAULT],
|
|
40
39
|
defaultBinding: DEFAULT,
|
|
41
40
|
turnType: "chat",
|
|
42
41
|
});
|
|
@@ -48,15 +47,15 @@ describe("the catalog one Turn is offered", () => {
|
|
|
48
47
|
expect(catalog[1]?.isDefault).toBe(false);
|
|
49
48
|
});
|
|
50
49
|
|
|
51
|
-
test("keeps one entry per slug when two
|
|
52
|
-
const twin =
|
|
50
|
+
test("keeps one entry per slug when two bindings name one model", () => {
|
|
51
|
+
const twin = binding(
|
|
53
52
|
"provider-ollama-cloud",
|
|
54
53
|
"glm-5.3-flash:cloud",
|
|
55
54
|
"asg-twin",
|
|
56
55
|
);
|
|
57
56
|
expect(
|
|
58
57
|
subagentModelCatalogV1({
|
|
59
|
-
|
|
58
|
+
bindings: [DEFAULT, twin],
|
|
60
59
|
defaultBinding: DEFAULT,
|
|
61
60
|
turnType: "chat",
|
|
62
61
|
}),
|
|
@@ -66,7 +65,7 @@ describe("the catalog one Turn is offered", () => {
|
|
|
66
65
|
test("renders exactly one slug on an automation turn — the Bot's own binding", () => {
|
|
67
66
|
for (const turnType of ["automation", "subagent"] as const) {
|
|
68
67
|
const catalog = subagentModelCatalogV1({
|
|
69
|
-
|
|
68
|
+
bindings: [SECOND, DEFAULT],
|
|
70
69
|
defaultBinding: DEFAULT,
|
|
71
70
|
turnType,
|
|
72
71
|
});
|
|
@@ -76,16 +75,16 @@ describe("the catalog one Turn is offered", () => {
|
|
|
76
75
|
}
|
|
77
76
|
});
|
|
78
77
|
|
|
79
|
-
test("is empty for a Bot with no enabled model
|
|
80
|
-
expect(
|
|
81
|
-
|
|
82
|
-
)
|
|
78
|
+
test("is empty for a Bot with no enabled model binding", () => {
|
|
79
|
+
expect(subagentModelCatalogV1({ bindings: [], turnType: "chat" })).toEqual(
|
|
80
|
+
[],
|
|
81
|
+
);
|
|
83
82
|
});
|
|
84
83
|
});
|
|
85
84
|
|
|
86
85
|
describe("resolving a `Task` model against the catalog", () => {
|
|
87
86
|
const catalog = subagentModelCatalogV1({
|
|
88
|
-
|
|
87
|
+
bindings: [SECOND, DEFAULT],
|
|
89
88
|
defaultBinding: DEFAULT,
|
|
90
89
|
turnType: "chat",
|
|
91
90
|
});
|
|
@@ -98,14 +97,14 @@ describe("resolving a `Task` model against the catalog", () => {
|
|
|
98
97
|
});
|
|
99
98
|
});
|
|
100
99
|
|
|
101
|
-
test("a named slug resolves to the
|
|
100
|
+
test("a named slug resolves to the binding that carries it", () => {
|
|
102
101
|
const resolved = resolveSubagentModelV1(
|
|
103
102
|
catalog,
|
|
104
103
|
"provider-foundation/sand-automation",
|
|
105
104
|
);
|
|
106
105
|
expect(resolved).toMatchObject({
|
|
107
106
|
status: "resolved",
|
|
108
|
-
model: { binding: {
|
|
107
|
+
model: { binding: { connectionId: SECOND.connectionId } },
|
|
109
108
|
});
|
|
110
109
|
});
|
|
111
110
|
|
|
@@ -119,7 +118,7 @@ describe("resolving a `Task` model against the catalog", () => {
|
|
|
119
118
|
|
|
120
119
|
test("an automation turn refuses the second slug it was not shown", () => {
|
|
121
120
|
const narrowed = subagentModelCatalogV1({
|
|
122
|
-
|
|
121
|
+
bindings: [SECOND, DEFAULT],
|
|
123
122
|
defaultBinding: DEFAULT,
|
|
124
123
|
turnType: "automation",
|
|
125
124
|
});
|
|
@@ -128,7 +127,7 @@ describe("resolving a `Task` model against the catalog", () => {
|
|
|
128
127
|
).toMatchObject({ status: "refused" });
|
|
129
128
|
});
|
|
130
129
|
|
|
131
|
-
test("a Bot with no model
|
|
130
|
+
test("a Bot with no model binding is refused rather than defaulted", () => {
|
|
132
131
|
expect(resolveSubagentModelV1([], undefined)).toMatchObject({
|
|
133
132
|
status: "refused",
|
|
134
133
|
});
|
|
@@ -142,7 +141,7 @@ describe("<available_subagent_models>", () => {
|
|
|
142
141
|
test("renders one element per slug and marks the default", () => {
|
|
143
142
|
const rendered = renderAvailableSubagentModelsPromptV1(
|
|
144
143
|
subagentModelCatalogV1({
|
|
145
|
-
|
|
144
|
+
bindings: [SECOND, DEFAULT],
|
|
146
145
|
defaultBinding: DEFAULT,
|
|
147
146
|
turnType: "chat",
|
|
148
147
|
}),
|
|
@@ -159,7 +158,7 @@ describe("<available_subagent_models>", () => {
|
|
|
159
158
|
test("renders exactly one model line on an automation turn", () => {
|
|
160
159
|
const rendered = renderAvailableSubagentModelsPromptV1(
|
|
161
160
|
subagentModelCatalogV1({
|
|
162
|
-
|
|
161
|
+
bindings: [SECOND, DEFAULT],
|
|
163
162
|
defaultBinding: DEFAULT,
|
|
164
163
|
turnType: "automation",
|
|
165
164
|
}),
|
package/src/models.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// default binding, GrokBot's `sand-automation` — and an omitted `model`
|
|
8
8
|
// inherits the parent's binding rather than picking anything.
|
|
9
9
|
//
|
|
10
|
-
// A slug is `<packageId>/<providerModelId>`. It names an
|
|
11
|
-
// provider: resolution runs against the
|
|
10
|
+
// A slug is `<packageId>/<providerModelId>`. It names an enabled binding, not a
|
|
11
|
+
// provider: resolution runs against the User's enabled model Capabilities, so a
|
|
12
12
|
// slug the Bot invents resolves to nothing and the dispatch is refused.
|
|
13
13
|
|
|
14
14
|
import type { TurnTypeV1 } from "@frockbot/kernel-contracts";
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
/** Most slugs the prompt section will ever render. A catalog, not a directory. */
|
|
23
23
|
export const SUBAGENT_MODEL_CATALOG_LIMIT_V1 = 32;
|
|
24
24
|
|
|
25
|
-
/** One offerable model, projected from one enabled model
|
|
25
|
+
/** One offerable model, projected from one enabled model binding. */
|
|
26
26
|
export interface SubagentModelOptionV1 {
|
|
27
27
|
slug: string;
|
|
28
28
|
binding: TaskModelBindingV1;
|
|
@@ -41,7 +41,7 @@ export function subagentModelSlugV1(binding: {
|
|
|
41
41
|
* The turn types that may see more than one slug.
|
|
42
42
|
*
|
|
43
43
|
* An `automation` or `subagent` turn renders exactly one — the Bot's default
|
|
44
|
-
* binding. It is not a permission (the
|
|
44
|
+
* binding. It is not a permission (the enabled set is the same either way); it
|
|
45
45
|
* is that an unattended Turn choosing a model per task is a decision with
|
|
46
46
|
* nobody to answer for it.
|
|
47
47
|
*/
|
|
@@ -52,12 +52,12 @@ export function subagentModelsAreNarrowedV1(turnType: TurnTypeV1): boolean {
|
|
|
52
52
|
/**
|
|
53
53
|
* The catalog one Turn is offered.
|
|
54
54
|
*
|
|
55
|
-
* `
|
|
55
|
+
* `bindings` are the User's enabled model bindings, already resolved by
|
|
56
56
|
* the Shell; `defaultBinding` is the Bot's own durable binding. A duplicate
|
|
57
|
-
* slug is kept once — two
|
|
57
|
+
* slug is kept once — two bindings naming one provider model are one choice.
|
|
58
58
|
*/
|
|
59
59
|
export function subagentModelCatalogV1(input: {
|
|
60
|
-
|
|
60
|
+
bindings: readonly TaskModelBindingV1[];
|
|
61
61
|
defaultBinding?: TaskModelBindingV1;
|
|
62
62
|
turnType: TurnTypeV1;
|
|
63
63
|
}): SubagentModelOptionV1[] {
|
|
@@ -74,10 +74,10 @@ export function subagentModelCatalogV1(input: {
|
|
|
74
74
|
options.push({ slug, binding, isDefault: slug === defaultSlug });
|
|
75
75
|
};
|
|
76
76
|
// The default first, so the one slug a narrowed turn renders is always the
|
|
77
|
-
// Bot's own binding and never whichever
|
|
77
|
+
// Bot's own binding and never whichever binding happens to sort first.
|
|
78
78
|
if (input.defaultBinding) consider(input.defaultBinding);
|
|
79
79
|
if (!subagentModelsAreNarrowedV1(input.turnType)) {
|
|
80
|
-
for (const
|
|
80
|
+
for (const binding of input.bindings) consider(binding);
|
|
81
81
|
}
|
|
82
82
|
return subagentModelsAreNarrowedV1(input.turnType)
|
|
83
83
|
? options.slice(0, 1)
|
|
@@ -106,7 +106,7 @@ export function resolveSubagentModelV1(
|
|
|
106
106
|
return {
|
|
107
107
|
status: "refused",
|
|
108
108
|
reason:
|
|
109
|
-
"this Bot has no enabled model
|
|
109
|
+
"this Bot has no enabled model binding, so a subagent has no model to run on",
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
112
|
return {
|
|
@@ -122,7 +122,7 @@ export function resolveSubagentModelV1(
|
|
|
122
122
|
reason:
|
|
123
123
|
offered.length > 0
|
|
124
124
|
? `model "${requested}" is not one of this Turn's subagent models (${offered})`
|
|
125
|
-
: `model "${requested}" is not available: this Bot has no enabled model
|
|
125
|
+
: `model "${requested}" is not available: this Bot has no enabled model binding`,
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
128
|
return {
|
package/src/records.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
decodeTaskModelBindingV1,
|
|
6
6
|
decodeTaskOutcomeV1,
|
|
7
7
|
decodeTaskRecordV1,
|
|
8
|
+
migrateStoredTaskRecordV1,
|
|
8
9
|
taskPromptDigestV1,
|
|
9
10
|
utf8ByteLengthV1,
|
|
10
11
|
TASK_ATTACHMENT_LIMIT_V1,
|
|
@@ -18,7 +19,6 @@ import {
|
|
|
18
19
|
} from "./records.js";
|
|
19
20
|
|
|
20
21
|
const BINDING = {
|
|
21
|
-
assignmentId: "asg-1",
|
|
22
22
|
packageId: "provider-ollama-cloud",
|
|
23
23
|
capabilityId: "ollama-cloud-models",
|
|
24
24
|
connectionId: "conn-1",
|
|
@@ -74,6 +74,35 @@ describe("the bounds the plan states", () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
describe("TaskRecordV1 decodes exactly", () => {
|
|
77
|
+
test("migrates the pre-account-wide model binding and keeps unknown fields strict", () => {
|
|
78
|
+
// Literal durable shape from ff25b5c84d963d9c25fb3e13aaeb9688fabc10c1.
|
|
79
|
+
const stored = taskRecord({
|
|
80
|
+
model: {
|
|
81
|
+
binding: {
|
|
82
|
+
assignmentId: "asg-1",
|
|
83
|
+
...BINDING,
|
|
84
|
+
},
|
|
85
|
+
slug: "provider-ollama-cloud/glm-5.3-flash",
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(decodeTaskRecordV1(migrateStoredTaskRecordV1(stored))).toEqual(
|
|
89
|
+
taskRecord() as TaskRecordV1,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const unknown = taskRecord({
|
|
93
|
+
model: {
|
|
94
|
+
binding: { assignmentId: "asg-1", ...BINDING, unknown: true },
|
|
95
|
+
slug: "provider-ollama-cloud/glm-5.3-flash:cloud",
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
expect(() =>
|
|
99
|
+
decodeTaskRecordV1(migrateStoredTaskRecordV1(unknown)),
|
|
100
|
+
).toThrow(/unknown field "unknown"/);
|
|
101
|
+
|
|
102
|
+
const current = taskRecord();
|
|
103
|
+
expect(migrateStoredTaskRecordV1(current)).toBe(current);
|
|
104
|
+
});
|
|
105
|
+
|
|
77
106
|
test("accepts a queued record and returns it field for field", () => {
|
|
78
107
|
const decoded = decodeTaskRecordV1(taskRecord());
|
|
79
108
|
expect(decoded).toEqual(taskRecord() as TaskRecordV1);
|
package/src/records.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// therefore parent state.
|
|
9
9
|
//
|
|
10
10
|
// Every record is versioned and exact-field, decoded at the seam it crosses.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// A previous stored shape crosses its explicit forward migration first; an
|
|
12
|
+
// unknown shape remains a visible failure.
|
|
13
13
|
|
|
14
14
|
/** The five subagent roles GrokBot declares (`docs/research/grokbot-computer.md` l.351–356). */
|
|
15
15
|
export const TASK_TYPES_V1 = [
|
|
@@ -196,7 +196,6 @@ function subagentFlag(value: unknown, label: string): boolean {
|
|
|
196
196
|
* resolves a binding of its own.
|
|
197
197
|
*/
|
|
198
198
|
export interface TaskModelBindingV1 {
|
|
199
|
-
assignmentId: string;
|
|
200
199
|
packageId: string;
|
|
201
200
|
capabilityId: string;
|
|
202
201
|
connectionId: string;
|
|
@@ -214,7 +213,6 @@ export function decodeTaskModelBindingV1(
|
|
|
214
213
|
subagentExactKeys(
|
|
215
214
|
candidate,
|
|
216
215
|
[
|
|
217
|
-
"assignmentId",
|
|
218
216
|
"packageId",
|
|
219
217
|
"capabilityId",
|
|
220
218
|
"connectionId",
|
|
@@ -225,11 +223,6 @@ export function decodeTaskModelBindingV1(
|
|
|
225
223
|
label,
|
|
226
224
|
);
|
|
227
225
|
return {
|
|
228
|
-
assignmentId: subagentText(
|
|
229
|
-
candidate.assignmentId,
|
|
230
|
-
128,
|
|
231
|
-
`${label}.assignmentId`,
|
|
232
|
-
),
|
|
233
226
|
packageId: subagentText(candidate.packageId, 128, `${label}.packageId`),
|
|
234
227
|
capabilityId: subagentText(
|
|
235
228
|
candidate.capabilityId,
|
|
@@ -388,6 +381,68 @@ export interface TaskRecordV1 {
|
|
|
388
381
|
outcome?: TaskOutcomeV1;
|
|
389
382
|
}
|
|
390
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Migrates the raw Task shape stored before account-wide model binding. Commit
|
|
386
|
+
* 03034e0 removed `assignmentId` from the nested binding along with the
|
|
387
|
+
* Assignment feature; it is discarded here and never interpreted.
|
|
388
|
+
*/
|
|
389
|
+
export function migrateStoredTaskRecordV1(stored: unknown): unknown {
|
|
390
|
+
const task = migrationRecordV1(stored);
|
|
391
|
+
if (!task || migrationDataValueV1(task, "schemaVersion") !== 1) return stored;
|
|
392
|
+
const model = migrationRecordV1(migrationDataValueV1(task, "model"));
|
|
393
|
+
if (!model) return stored;
|
|
394
|
+
const binding = migrationRecordV1(migrationDataValueV1(model, "binding"));
|
|
395
|
+
if (!binding || !Object.hasOwn(binding, "assignmentId")) return stored;
|
|
396
|
+
const nextBinding = migrationCloneV1(binding, {}, ["assignmentId"]);
|
|
397
|
+
const nextModel = migrationCloneV1(model, { binding: nextBinding });
|
|
398
|
+
return migrationCloneV1(task, { model: nextModel });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function migrationRecordV1(
|
|
402
|
+
value: unknown,
|
|
403
|
+
): Record<string, unknown> | undefined {
|
|
404
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
const prototype = Object.getPrototypeOf(value);
|
|
408
|
+
return prototype === Object.prototype || prototype === null
|
|
409
|
+
? (value as Record<string, unknown>)
|
|
410
|
+
: undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function migrationDataValueV1(
|
|
414
|
+
value: Record<string, unknown>,
|
|
415
|
+
key: string,
|
|
416
|
+
): unknown {
|
|
417
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
418
|
+
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function migrationCloneV1(
|
|
422
|
+
value: Record<string, unknown>,
|
|
423
|
+
changes: Readonly<Record<string, unknown>>,
|
|
424
|
+
removed: readonly string[] = [],
|
|
425
|
+
): Record<string, unknown> {
|
|
426
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
427
|
+
for (const key of removed) delete descriptors[key];
|
|
428
|
+
for (const [key, next] of Object.entries(changes)) {
|
|
429
|
+
const descriptor = descriptors[key];
|
|
430
|
+
descriptors[key] =
|
|
431
|
+
descriptor && "value" in descriptor
|
|
432
|
+
? { ...descriptor, value: next }
|
|
433
|
+
: {
|
|
434
|
+
configurable: true,
|
|
435
|
+
enumerable: true,
|
|
436
|
+
value: next,
|
|
437
|
+
writable: true,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
return Object.create(Object.getPrototypeOf(value), descriptors) as Record<
|
|
441
|
+
string,
|
|
442
|
+
unknown
|
|
443
|
+
>;
|
|
444
|
+
}
|
|
445
|
+
|
|
391
446
|
function decodeTaskAttachmentsV1(value: unknown, label: string): string[] {
|
|
392
447
|
if (!Array.isArray(value)) {
|
|
393
448
|
throw new SubagentDecodeError(`${label} must be an array`);
|
package/src/store.test.ts
CHANGED
|
@@ -6,11 +6,10 @@ import {
|
|
|
6
6
|
TASK_DEADLINE_MS_V1,
|
|
7
7
|
TASK_MESSAGE_QUEUE_LIMIT_V1,
|
|
8
8
|
} from "./records.js";
|
|
9
|
-
import { TASK_DESKTOP_LEASE_KEY } from "./storage-keys.js";
|
|
9
|
+
import { TASK_DESKTOP_LEASE_KEY, taskKeyV1 } from "./storage-keys.js";
|
|
10
10
|
|
|
11
11
|
const MODEL = {
|
|
12
12
|
binding: {
|
|
13
|
-
assignmentId: "asg-1",
|
|
14
13
|
packageId: "provider-ollama-cloud",
|
|
15
14
|
capabilityId: "ollama-cloud-models",
|
|
16
15
|
connectionId: "conn-1",
|
|
@@ -40,6 +39,45 @@ function request(
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
describe("admitting a task", () => {
|
|
42
|
+
test("reads an old model binding purely and writes it forward on lifecycle change", async () => {
|
|
43
|
+
const storage = createMemorySubagentStorageV1();
|
|
44
|
+
const store = new TaskStore(storage);
|
|
45
|
+
await store.admit(request("tk-migrate"));
|
|
46
|
+
const current = await storage.get<Record<string, unknown>>(
|
|
47
|
+
taskKeyV1("tk-migrate"),
|
|
48
|
+
);
|
|
49
|
+
if (!current) throw new Error("test task was not stored");
|
|
50
|
+
const currentModel = current.model as {
|
|
51
|
+
binding: Record<string, unknown>;
|
|
52
|
+
slug: string;
|
|
53
|
+
};
|
|
54
|
+
// `assignmentId` is the literal nested field removed by 03034e0.
|
|
55
|
+
const historical = {
|
|
56
|
+
...current,
|
|
57
|
+
model: {
|
|
58
|
+
...currentModel,
|
|
59
|
+
binding: { assignmentId: "asg-1", ...currentModel.binding },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
await storage.put(taskKeyV1("tk-migrate"), historical);
|
|
63
|
+
|
|
64
|
+
await expect(store.read("tk-migrate")).resolves.toMatchObject({
|
|
65
|
+
status: "queued",
|
|
66
|
+
model: { binding: MODEL.binding },
|
|
67
|
+
});
|
|
68
|
+
expect(await storage.get<unknown>(taskKeyV1("tk-migrate"))).toEqual(
|
|
69
|
+
historical,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await store.markRunning("tk-migrate");
|
|
73
|
+
const written = await storage.get<{
|
|
74
|
+
status: string;
|
|
75
|
+
model: { binding: object };
|
|
76
|
+
}>(taskKeyV1("tk-migrate"));
|
|
77
|
+
expect(written).toMatchObject({ status: "running" });
|
|
78
|
+
expect(written?.model.binding).not.toHaveProperty("assignmentId");
|
|
79
|
+
});
|
|
80
|
+
|
|
43
81
|
test("writes the record, the active key and the index row in one go", async () => {
|
|
44
82
|
const storage = createMemorySubagentStorageV1();
|
|
45
83
|
const store = new TaskStore(storage);
|
package/src/store.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
decodeTaskRecordV1,
|
|
23
23
|
isTaskIdV1,
|
|
24
24
|
isTerminalTaskStatusV1,
|
|
25
|
+
migrateStoredTaskRecordV1,
|
|
25
26
|
SubagentDecodeError,
|
|
26
27
|
TASK_CONCURRENCY_PER_BOT_V1,
|
|
27
28
|
TASK_DEADLINE_MS_V1,
|
|
@@ -139,7 +140,10 @@ export class TaskStore {
|
|
|
139
140
|
if (existing !== undefined) {
|
|
140
141
|
// A resumed Turn re-executing the same tool call reads its own task
|
|
141
142
|
// back rather than dispatching a second child.
|
|
142
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
status: "replayed",
|
|
145
|
+
record: decodeStoredTaskRecordV1(existing),
|
|
146
|
+
};
|
|
143
147
|
}
|
|
144
148
|
const active = await transaction.list<unknown>({
|
|
145
149
|
prefix: TASK_ACTIVE_PREFIX,
|
|
@@ -266,7 +270,7 @@ export class TaskStore {
|
|
|
266
270
|
const holder = await transaction.get<unknown>(taskKeyV1(lease.taskId));
|
|
267
271
|
if (holder === undefined) return undefined;
|
|
268
272
|
try {
|
|
269
|
-
if (isTerminalTaskStatusV1(
|
|
273
|
+
if (isTerminalTaskStatusV1(decodeStoredTaskRecordV1(holder).status)) {
|
|
270
274
|
return undefined;
|
|
271
275
|
}
|
|
272
276
|
} catch {
|
|
@@ -596,7 +600,7 @@ export class TaskStore {
|
|
|
596
600
|
if (!isTaskIdV1(taskId)) throw new TaskNotFoundError(String(taskId));
|
|
597
601
|
const stored = await reads.get<unknown>(taskKeyV1(taskId));
|
|
598
602
|
if (stored === undefined) throw new TaskNotFoundError(taskId);
|
|
599
|
-
return
|
|
603
|
+
return decodeStoredTaskRecordV1(stored);
|
|
600
604
|
}
|
|
601
605
|
|
|
602
606
|
async read(taskId: string): Promise<TaskRecordV1> {
|
|
@@ -671,12 +675,16 @@ export class TaskStore {
|
|
|
671
675
|
// `task:` is a prefix of `task-active:` in neither direction — the
|
|
672
676
|
// separator differs — but list is a byte-range scan, so be exact.
|
|
673
677
|
if (!key.startsWith(TASK_PREFIX)) continue;
|
|
674
|
-
records.push(
|
|
678
|
+
records.push(decodeStoredTaskRecordV1(value));
|
|
675
679
|
}
|
|
676
680
|
return records;
|
|
677
681
|
}
|
|
678
682
|
}
|
|
679
683
|
|
|
684
|
+
function decodeStoredTaskRecordV1(stored: unknown): TaskRecordV1 {
|
|
685
|
+
return decodeTaskRecordV1(migrateStoredTaskRecordV1(stored));
|
|
686
|
+
}
|
|
687
|
+
|
|
680
688
|
/**
|
|
681
689
|
* The typed refusal a second `computerUse` dispatch reads.
|
|
682
690
|
*
|