@frockbot/plugin-routines 0.0.0 → 0.1.1
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/frockbot.json +39 -0
- package/package.json +53 -6
- package/src/agent.test.ts +206 -0
- package/src/agent.ts +345 -0
- package/src/backend.test.ts +181 -0
- package/src/backend.ts +375 -0
- package/src/client/RoutineInboxBadge.vue +216 -0
- package/src/client/RoutinesSection.vue +601 -0
- package/src/client/RoutinesSummary.vue +150 -0
- package/src/client/index.test.ts +209 -0
- package/src/client/index.ts +289 -0
- package/src/client/state.ts +65 -0
- package/src/cron.test.ts +217 -0
- package/src/cron.ts +246 -0
- package/src/env.d.ts +6 -0
- package/src/firing.ts +222 -0
- package/src/hook.test.ts +394 -0
- package/src/hook.ts +405 -0
- package/src/inbox-store.ts +405 -0
- package/src/inbox.test.ts +402 -0
- package/src/inbox.ts +405 -0
- package/src/index.ts +9 -0
- package/src/manifest.ts +3 -0
- package/src/records.test.ts +138 -0
- package/src/records.ts +341 -0
- package/src/scheduler.test.ts +482 -0
- package/src/scheduler.ts +551 -0
- package/src/shared.test.ts +141 -0
- package/src/shared.ts +988 -0
- package/src/storage-keys.ts +202 -0
- package/src/store.test.ts +261 -0
- package/src/store.ts +789 -0
- package/src/testing.ts +55 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +31 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 4,
|
|
3
|
+
"id": "routines",
|
|
4
|
+
"displayName": "Routines",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"compatibility": { "frockbot": ">=0.0.1" },
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"shell": ">=0.0.1",
|
|
9
|
+
"ui-theme": ">=0.0.1"
|
|
10
|
+
},
|
|
11
|
+
"contributions": {
|
|
12
|
+
"backend": [{ "entry": "./backend", "host": "gateway" }],
|
|
13
|
+
"runtime": { "entry": "./agent" },
|
|
14
|
+
"client": {
|
|
15
|
+
"entry": "./client",
|
|
16
|
+
"mounts": [
|
|
17
|
+
{ "slot": "frockbot.bot-settings-sections", "order": 10 },
|
|
18
|
+
{ "slot": "frockbot.bot-panel-sections", "order": 10 }
|
|
19
|
+
],
|
|
20
|
+
"outlets": []
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"configuration": {
|
|
24
|
+
"settings": [],
|
|
25
|
+
"connectionTypes": [],
|
|
26
|
+
"capabilities": [
|
|
27
|
+
{
|
|
28
|
+
"id": "routine-tools",
|
|
29
|
+
"kind": "tool",
|
|
30
|
+
"connectionTypes": [],
|
|
31
|
+
"admission": {
|
|
32
|
+
"turnTypes": ["chat", "automation", "subagent"],
|
|
33
|
+
"subagentRoles": ["executor"]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"permissions": ["routines:read", "routines:write"]
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-routines",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./agent": "./src/agent.ts",
|
|
9
|
+
"./backend": "./src/backend.ts",
|
|
10
|
+
"./client": "./src/client/index.ts",
|
|
11
|
+
"./cron": "./src/cron.ts",
|
|
12
|
+
"./firing": "./src/firing.ts",
|
|
13
|
+
"./hook": "./src/hook.ts",
|
|
14
|
+
"./frockbot.json": "./frockbot.json",
|
|
15
|
+
"./inbox": "./src/inbox.ts",
|
|
16
|
+
"./inbox-store": "./src/inbox-store.ts",
|
|
17
|
+
"./manifest": "./src/manifest.ts",
|
|
18
|
+
"./package.json": "./package.json",
|
|
19
|
+
"./records": "./src/records.ts",
|
|
20
|
+
"./scheduler": "./src/scheduler.ts",
|
|
21
|
+
"./shared": "./src/shared.ts",
|
|
22
|
+
"./storage-keys": "./src/storage-keys.ts",
|
|
23
|
+
"./store": "./src/store.ts",
|
|
24
|
+
"./testing": "./src/testing.ts"
|
|
25
|
+
},
|
|
26
|
+
"frockbot": {
|
|
27
|
+
"manifest": "./frockbot.json"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "bun test src",
|
|
31
|
+
"build": "vite build",
|
|
32
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@frockbot/client-core": "0.1.1",
|
|
36
|
+
"@frockbot/client-ui": "0.1.1",
|
|
37
|
+
"@frockbot/configuration-core": "0.1.1",
|
|
38
|
+
"@frockbot/kernel-agent-loop": "0.1.1",
|
|
39
|
+
"@frockbot/kernel-contracts": "0.1.1",
|
|
40
|
+
"@frockbot/plugin-shell": "0.1.1",
|
|
41
|
+
"cordis": "4.0.0-rc.8",
|
|
42
|
+
"croner": "10.0.1",
|
|
43
|
+
"vue": "3.5.41"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/bun": "1.4.0",
|
|
47
|
+
"@types/node": "26.2.0",
|
|
48
|
+
"@vitejs/plugin-vue": "6.0.8",
|
|
49
|
+
"typescript": "5.9.3",
|
|
50
|
+
"vite": "8.2.2",
|
|
51
|
+
"vue-tsc": "3.3.10"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
6
56
|
"repository": {
|
|
7
57
|
"type": "git",
|
|
8
58
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
59
|
"directory": "packages/plugin-routines"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
60
|
}
|
|
14
61
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createRoutineManageTool,
|
|
4
|
+
routineManageCommandV1,
|
|
5
|
+
routineToolCommandIdV1,
|
|
6
|
+
type RoutinesRuntimeHostV1,
|
|
7
|
+
} from "./agent.js";
|
|
8
|
+
import { RoutineStore } from "./store.js";
|
|
9
|
+
import { createMemoryRoutineStorageV1 } from "./testing.js";
|
|
10
|
+
|
|
11
|
+
const WRITER = {
|
|
12
|
+
sessionId: "tim:scout",
|
|
13
|
+
turnId: "turn-4",
|
|
14
|
+
runId: "run-9",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CONTEXT = {
|
|
18
|
+
botId: "scout",
|
|
19
|
+
agentId: "scout",
|
|
20
|
+
sessionId: "tim:scout",
|
|
21
|
+
compositionGenerationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
|
|
22
|
+
turnType: "chat" as const,
|
|
23
|
+
effectId: "tool:1:1:0",
|
|
24
|
+
signal: new AbortController().signal,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function host(): RoutinesRuntimeHostV1 & { store: RoutineStore } {
|
|
28
|
+
const store = new RoutineStore(createMemoryRoutineStorageV1(), {
|
|
29
|
+
defaultTimezone: "Australia/Sydney",
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
botId: "scout",
|
|
33
|
+
writer: WRITER,
|
|
34
|
+
store,
|
|
35
|
+
list: () => store.list("scout"),
|
|
36
|
+
execute: (command, writer) => store.execute(command, writer),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("routine_manage", () => {
|
|
41
|
+
test("creates a Routine through the same command path the client uses", async () => {
|
|
42
|
+
const seam = host();
|
|
43
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
44
|
+
const result = await tool.execute(
|
|
45
|
+
{
|
|
46
|
+
action: "create",
|
|
47
|
+
name: "Morning brief",
|
|
48
|
+
prompt: "Summarize overnight email.",
|
|
49
|
+
schedule: "@daily",
|
|
50
|
+
},
|
|
51
|
+
CONTEXT,
|
|
52
|
+
);
|
|
53
|
+
expect(result.isError).toBe(false);
|
|
54
|
+
const listed = await seam.list();
|
|
55
|
+
expect(listed.routines).toHaveLength(1);
|
|
56
|
+
expect(listed.routines[0]).toMatchObject({
|
|
57
|
+
name: "Morning brief",
|
|
58
|
+
schedule: "@daily",
|
|
59
|
+
timezone: "Australia/Sydney",
|
|
60
|
+
createdBy: { kind: "bot", botId: "scout" },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("records the Session and Turn of the writing Bot", async () => {
|
|
65
|
+
const seam = host();
|
|
66
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
67
|
+
await tool.execute(
|
|
68
|
+
{
|
|
69
|
+
action: "create",
|
|
70
|
+
routineId: "brief",
|
|
71
|
+
name: "Brief",
|
|
72
|
+
prompt: "Do it",
|
|
73
|
+
trigger: "webhook",
|
|
74
|
+
},
|
|
75
|
+
CONTEXT,
|
|
76
|
+
);
|
|
77
|
+
const record = await seam.store.read("brief");
|
|
78
|
+
expect(record?.createdBy).toEqual({
|
|
79
|
+
kind: "bot",
|
|
80
|
+
botId: "scout",
|
|
81
|
+
sessionId: "tim:scout",
|
|
82
|
+
turnId: "turn-4",
|
|
83
|
+
});
|
|
84
|
+
expect(record?.trigger).toEqual({ kind: "webhook" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a repeated call under one effect identifier writes once", async () => {
|
|
88
|
+
const seam = host();
|
|
89
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
90
|
+
const input = {
|
|
91
|
+
action: "create",
|
|
92
|
+
name: "Brief",
|
|
93
|
+
prompt: "Do it",
|
|
94
|
+
schedule: "@daily",
|
|
95
|
+
};
|
|
96
|
+
await tool.execute(input, CONTEXT);
|
|
97
|
+
await tool.execute(input, CONTEXT);
|
|
98
|
+
expect((await seam.list()).routines).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("pause, resume and delete reach the record", async () => {
|
|
102
|
+
const seam = host();
|
|
103
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
104
|
+
await tool.execute(
|
|
105
|
+
{
|
|
106
|
+
action: "create",
|
|
107
|
+
routineId: "brief",
|
|
108
|
+
name: "Brief",
|
|
109
|
+
prompt: "Do it",
|
|
110
|
+
schedule: "@daily",
|
|
111
|
+
},
|
|
112
|
+
CONTEXT,
|
|
113
|
+
);
|
|
114
|
+
await tool.execute(
|
|
115
|
+
{ action: "pause", routineId: "brief" },
|
|
116
|
+
{
|
|
117
|
+
...CONTEXT,
|
|
118
|
+
effectId: "tool:1:2:0",
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
expect((await seam.store.read("brief"))?.enabled).toBe(false);
|
|
122
|
+
await tool.execute(
|
|
123
|
+
{ action: "resume", routineId: "brief" },
|
|
124
|
+
{
|
|
125
|
+
...CONTEXT,
|
|
126
|
+
effectId: "tool:1:3:0",
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
expect((await seam.store.read("brief"))?.enabled).toBe(true);
|
|
130
|
+
const deleted = await tool.execute(
|
|
131
|
+
{ action: "delete", routineId: "brief" },
|
|
132
|
+
{ ...CONTEXT, effectId: "tool:1:4:0" },
|
|
133
|
+
);
|
|
134
|
+
expect(deleted.content).toContain("Deleted Routine brief");
|
|
135
|
+
expect(await seam.store.read("brief")).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a bad cron and a missing id are observable refusals, not throws", async () => {
|
|
139
|
+
const seam = host();
|
|
140
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
141
|
+
const badCron = await tool.execute(
|
|
142
|
+
{
|
|
143
|
+
action: "create",
|
|
144
|
+
name: "Brief",
|
|
145
|
+
prompt: "Do it",
|
|
146
|
+
schedule: "not a cron",
|
|
147
|
+
},
|
|
148
|
+
CONTEXT,
|
|
149
|
+
);
|
|
150
|
+
expect(badCron).toMatchObject({ isError: true });
|
|
151
|
+
expect(badCron.content).toContain("five fields");
|
|
152
|
+
const missingId = await tool.execute(
|
|
153
|
+
{ action: "pause" },
|
|
154
|
+
{ ...CONTEXT, effectId: "tool:1:2:0" },
|
|
155
|
+
);
|
|
156
|
+
expect(missingId).toMatchObject({ isError: true });
|
|
157
|
+
expect(missingId.content).toContain("needs a routineId");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("names no turn types of its own, so its Capability's ceiling decides", () => {
|
|
161
|
+
const seam = host();
|
|
162
|
+
const admission = createRoutineManageTool({
|
|
163
|
+
...seam,
|
|
164
|
+
writer: WRITER,
|
|
165
|
+
}).admission;
|
|
166
|
+
expect(admission?.turnTypes).toBeUndefined();
|
|
167
|
+
// It does narrow the second dimension: managing Routines is a general work
|
|
168
|
+
// tool, so only an `executor` subagent is offered it.
|
|
169
|
+
expect(admission?.subagentRoles).toEqual(["executor"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("refuses unknown input fields and an unknown action", () => {
|
|
173
|
+
const seam = host();
|
|
174
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
175
|
+
expect(tool.validate?.({ action: "backfill", routineId: "brief" })).toBe(
|
|
176
|
+
false,
|
|
177
|
+
);
|
|
178
|
+
expect(tool.validate?.({ action: "pause", secret: "x" })).toBe(false);
|
|
179
|
+
expect(tool.validate?.({ action: "pause", routineId: "brief" })).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("routineManageCommandV1", () => {
|
|
184
|
+
test("maps a webhook trigger to the record's trigger shape", () => {
|
|
185
|
+
expect(
|
|
186
|
+
routineManageCommandV1(
|
|
187
|
+
{
|
|
188
|
+
action: "create",
|
|
189
|
+
name: "Brief",
|
|
190
|
+
prompt: "Do it",
|
|
191
|
+
trigger: "webhook",
|
|
192
|
+
},
|
|
193
|
+
{ botId: "scout", commandId: "cmd-1" },
|
|
194
|
+
),
|
|
195
|
+
).toMatchObject({ type: "routine/create", trigger: { kind: "webhook" } });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("routineToolCommandIdV1", () => {
|
|
200
|
+
test("derives a stable identifier from the Turn's effect identifier", () => {
|
|
201
|
+
expect(routineToolCommandIdV1("tool:1:1:0")).toBe("rt-tool-1-1-0");
|
|
202
|
+
expect(routineToolCommandIdV1("tool:1:1:0")).toBe(
|
|
203
|
+
routineToolCommandIdV1("tool:1:1:0"),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// The Routines runtime Contribution: one tool, `routine_manage`.
|
|
2
|
+
//
|
|
3
|
+
// GrokBot's `update_state target=routine {create,update,pause,resume,delete}`
|
|
4
|
+
// (docs/research/grokbot-computer.md, row 19) reaches FrockBot as a single tool
|
|
5
|
+
// that calls the same command path the hosted client calls. There is no second
|
|
6
|
+
// way to write a Routine, so a Bot editing its own Routine and a User editing it
|
|
7
|
+
// produce the same durable record with different recorded provenance.
|
|
8
|
+
//
|
|
9
|
+
// "Self-modification never widens authority": a Bot-authored Routine runs as the
|
|
10
|
+
// Bot, with the Bot's Assignments. Nothing here grants anything.
|
|
11
|
+
//
|
|
12
|
+
// `run_now` fires the Routine out of band. It enqueues rather than runs: the
|
|
13
|
+
// tool is called from inside an admitted Turn, and a Bot Durable Object holds
|
|
14
|
+
// exactly one run at a time, so the firing is durable immediately and lands the
|
|
15
|
+
// moment the calling Turn settles. "Queue, never drop, never parallel."
|
|
16
|
+
import type {
|
|
17
|
+
ToolDefinition,
|
|
18
|
+
ToolExecutionContext,
|
|
19
|
+
} from "@frockbot/kernel-contracts";
|
|
20
|
+
// Merges the Agent loop's event declarations into the cordis Context type.
|
|
21
|
+
import type {} from "@frockbot/kernel-agent-loop/agent";
|
|
22
|
+
import type { Plugin } from "cordis";
|
|
23
|
+
import {
|
|
24
|
+
ROUTINE_NAME_MAX_LENGTH,
|
|
25
|
+
ROUTINE_PROMPT_MAX_LENGTH,
|
|
26
|
+
RoutineDecodeError,
|
|
27
|
+
type RoutineWriterV1,
|
|
28
|
+
} from "./records.js";
|
|
29
|
+
import {
|
|
30
|
+
decodeRoutineCommandV1,
|
|
31
|
+
type RoutineCommandReceiptV1,
|
|
32
|
+
type RoutineCommandV1,
|
|
33
|
+
type RoutineListViewV1,
|
|
34
|
+
} from "./shared.js";
|
|
35
|
+
|
|
36
|
+
/** The Session and Turn a Bot-authored Routine write records as its writer. */
|
|
37
|
+
export interface RoutineWriterIdentityV1 {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
turnId: string;
|
|
40
|
+
runId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The host seam this Package receives. The Durable Object supplies it for one
|
|
45
|
+
* admitted Turn: without `writer` there is no Turn to attribute a write to, and
|
|
46
|
+
* the tool is then not registered at all.
|
|
47
|
+
*/
|
|
48
|
+
export interface RoutinesRuntimeHostV1 {
|
|
49
|
+
botId: string;
|
|
50
|
+
writer?: RoutineWriterIdentityV1;
|
|
51
|
+
list(): Promise<RoutineListViewV1>;
|
|
52
|
+
execute(
|
|
53
|
+
command: RoutineCommandV1,
|
|
54
|
+
writer: RoutineWriterV1,
|
|
55
|
+
): Promise<RoutineCommandReceiptV1>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const ROUTINE_MANAGE_ACTIONS = [
|
|
59
|
+
"create",
|
|
60
|
+
"update",
|
|
61
|
+
"pause",
|
|
62
|
+
"resume",
|
|
63
|
+
"delete",
|
|
64
|
+
"run_now",
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
export type RoutineManageActionV1 = (typeof ROUTINE_MANAGE_ACTIONS)[number];
|
|
68
|
+
|
|
69
|
+
const ROUTINE_MANAGE_INPUT_SCHEMA = {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
action: {
|
|
73
|
+
type: "string",
|
|
74
|
+
enum: [...ROUTINE_MANAGE_ACTIONS],
|
|
75
|
+
description:
|
|
76
|
+
"What to do with the Routine. run_now queues one firing immediately; it lands after the current Turn.",
|
|
77
|
+
},
|
|
78
|
+
routineId: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description:
|
|
81
|
+
"The Routine to act on. Required for every action except create.",
|
|
82
|
+
},
|
|
83
|
+
name: { type: "string", description: "The Routine's display name." },
|
|
84
|
+
prompt: {
|
|
85
|
+
type: "string",
|
|
86
|
+
description: "The instruction the Routine runs when it fires.",
|
|
87
|
+
},
|
|
88
|
+
schedule: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description:
|
|
91
|
+
"A five-field cron expression, or @hourly, @daily, @weekly, @monthly, or @every 15m. Optionally prefixed with CRON_TZ=<zone>. A Routine has a schedule or a webhook trigger, never both.",
|
|
92
|
+
},
|
|
93
|
+
trigger: {
|
|
94
|
+
type: "string",
|
|
95
|
+
enum: ["webhook"],
|
|
96
|
+
description:
|
|
97
|
+
"Fire on a delivered webhook instead of on a clock. A Routine has a schedule or a trigger, never both.",
|
|
98
|
+
},
|
|
99
|
+
timezone: {
|
|
100
|
+
type: "string",
|
|
101
|
+
description:
|
|
102
|
+
"IANA time zone the schedule is read in, such as Australia/Sydney.",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
required: ["action"],
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
} as const;
|
|
108
|
+
|
|
109
|
+
interface RoutineManageInputV1 {
|
|
110
|
+
action: RoutineManageActionV1;
|
|
111
|
+
routineId?: string;
|
|
112
|
+
name?: string;
|
|
113
|
+
prompt?: string;
|
|
114
|
+
schedule?: string;
|
|
115
|
+
trigger?: "webhook";
|
|
116
|
+
timezone?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
|
|
120
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
121
|
+
throw new RoutineDecodeError("routine_manage input must be an object");
|
|
122
|
+
}
|
|
123
|
+
const value = input as Record<string, unknown>;
|
|
124
|
+
const allowed = new Set([
|
|
125
|
+
"action",
|
|
126
|
+
"routineId",
|
|
127
|
+
"name",
|
|
128
|
+
"prompt",
|
|
129
|
+
"schedule",
|
|
130
|
+
"trigger",
|
|
131
|
+
"timezone",
|
|
132
|
+
]);
|
|
133
|
+
for (const key of Object.keys(value)) {
|
|
134
|
+
if (!allowed.has(key)) {
|
|
135
|
+
throw new RoutineDecodeError(
|
|
136
|
+
`routine_manage input has unknown field "${key}"`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const action = ROUTINE_MANAGE_ACTIONS.find((known) => known === value.action);
|
|
141
|
+
if (!action) {
|
|
142
|
+
throw new RoutineDecodeError("routine_manage action is unknown");
|
|
143
|
+
}
|
|
144
|
+
const optional = (key: keyof RoutineManageInputV1): string | undefined => {
|
|
145
|
+
const candidate = value[key];
|
|
146
|
+
if (candidate === undefined) return undefined;
|
|
147
|
+
if (typeof candidate !== "string") {
|
|
148
|
+
throw new RoutineDecodeError(`routine_manage ${key} must be a string`);
|
|
149
|
+
}
|
|
150
|
+
return candidate;
|
|
151
|
+
};
|
|
152
|
+
if (value.trigger !== undefined && value.trigger !== "webhook") {
|
|
153
|
+
throw new RoutineDecodeError('routine_manage trigger must be "webhook"');
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
action,
|
|
157
|
+
...(optional("routineId") === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: { routineId: optional("routineId")! }),
|
|
160
|
+
...(optional("name") === undefined ? {} : { name: optional("name")! }),
|
|
161
|
+
...(optional("prompt") === undefined
|
|
162
|
+
? {}
|
|
163
|
+
: { prompt: optional("prompt")! }),
|
|
164
|
+
...(optional("schedule") === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { schedule: optional("schedule")! }),
|
|
167
|
+
...(value.trigger === undefined ? {} : { trigger: "webhook" as const }),
|
|
168
|
+
...(optional("timezone") === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: { timezone: optional("timezone")! }),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const COMMAND_ID_CHARACTER = /[^a-zA-Z0-9._-]/g;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The command id one tool call uses. It is derived from the Turn's effect
|
|
178
|
+
* identifier, so a reconciled or retried call replays the recorded receipt
|
|
179
|
+
* instead of writing a second Routine.
|
|
180
|
+
*/
|
|
181
|
+
export function routineToolCommandIdV1(effectId: string): string {
|
|
182
|
+
const sanitized = effectId.replace(COMMAND_ID_CHARACTER, "-").slice(0, 120);
|
|
183
|
+
return `rt-${sanitized || "call"}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The command one tool call becomes. Exported because it is the whole
|
|
188
|
+
* translation from a model's words to a durable command, and it is worth
|
|
189
|
+
* testing without a host.
|
|
190
|
+
*/
|
|
191
|
+
export function routineManageCommandV1(
|
|
192
|
+
input: RoutineManageInputV1,
|
|
193
|
+
meta: { botId: string; commandId: string },
|
|
194
|
+
): RoutineCommandV1 {
|
|
195
|
+
const base = {
|
|
196
|
+
schemaVersion: 1 as const,
|
|
197
|
+
commandId: meta.commandId,
|
|
198
|
+
botId: meta.botId,
|
|
199
|
+
};
|
|
200
|
+
if (input.action === "create") {
|
|
201
|
+
return decodeRoutineCommandV1({
|
|
202
|
+
...base,
|
|
203
|
+
type: "routine/create",
|
|
204
|
+
...(input.routineId === undefined ? {} : { routineId: input.routineId }),
|
|
205
|
+
name: input.name,
|
|
206
|
+
prompt: input.prompt,
|
|
207
|
+
...(input.schedule === undefined ? {} : { schedule: input.schedule }),
|
|
208
|
+
...(input.trigger === undefined
|
|
209
|
+
? {}
|
|
210
|
+
: { trigger: { kind: input.trigger } }),
|
|
211
|
+
...(input.timezone === undefined ? {} : { timezone: input.timezone }),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (input.routineId === undefined) {
|
|
215
|
+
throw new RoutineDecodeError(
|
|
216
|
+
`routine_manage ${input.action} needs a routineId`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (input.action === "update") {
|
|
220
|
+
return decodeRoutineCommandV1({
|
|
221
|
+
...base,
|
|
222
|
+
type: "routine/update",
|
|
223
|
+
routineId: input.routineId,
|
|
224
|
+
...(input.name === undefined ? {} : { name: input.name }),
|
|
225
|
+
...(input.prompt === undefined ? {} : { prompt: input.prompt }),
|
|
226
|
+
...(input.schedule === undefined ? {} : { schedule: input.schedule }),
|
|
227
|
+
...(input.trigger === undefined
|
|
228
|
+
? {}
|
|
229
|
+
: { trigger: { kind: input.trigger } }),
|
|
230
|
+
...(input.timezone === undefined ? {} : { timezone: input.timezone }),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return decodeRoutineCommandV1({
|
|
234
|
+
...base,
|
|
235
|
+
type:
|
|
236
|
+
input.action === "run_now" ? "routine/run" : `routine/${input.action}`,
|
|
237
|
+
routineId: input.routineId,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function refusal(reason: string): { content: string; isError: boolean } {
|
|
242
|
+
return { content: `routine_manage was refused: ${reason}`, isError: true };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function createRoutineManageTool(
|
|
246
|
+
host: RoutinesRuntimeHostV1 & { writer: RoutineWriterIdentityV1 },
|
|
247
|
+
): ToolDefinition {
|
|
248
|
+
return {
|
|
249
|
+
name: "routine_manage",
|
|
250
|
+
// A general work tool: the full toolset an `executor` subagent gets, and
|
|
251
|
+
// not part of the narrow reach of `browserUse`, `computerUse`, or the two
|
|
252
|
+
// video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
|
|
253
|
+
admission: { subagentRoles: ["executor"] },
|
|
254
|
+
description: [
|
|
255
|
+
"Create, edit, pause, resume, delete, or immediately run one of your own Routines.",
|
|
256
|
+
"A Routine is a standing instruction that fires on a schedule or on a delivered webhook,",
|
|
257
|
+
`as its own Turn rather than inside this conversation. Names are at most ${ROUTINE_NAME_MAX_LENGTH}`,
|
|
258
|
+
`characters and prompts at most ${ROUTINE_PROMPT_MAX_LENGTH}.`,
|
|
259
|
+
].join(" "),
|
|
260
|
+
inputSchema: ROUTINE_MANAGE_INPUT_SCHEMA as unknown as Record<
|
|
261
|
+
string,
|
|
262
|
+
unknown
|
|
263
|
+
>,
|
|
264
|
+
idempotent: false,
|
|
265
|
+
validate: (input: unknown) => {
|
|
266
|
+
try {
|
|
267
|
+
decodeRoutineManageInputV1(input);
|
|
268
|
+
return true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
execute: async (input: unknown, context: ToolExecutionContext) => {
|
|
274
|
+
let command: RoutineCommandV1;
|
|
275
|
+
try {
|
|
276
|
+
command = routineManageCommandV1(decodeRoutineManageInputV1(input), {
|
|
277
|
+
botId: host.botId,
|
|
278
|
+
commandId: routineToolCommandIdV1(context.effectId),
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return refusal(error instanceof Error ? error.message : String(error));
|
|
282
|
+
}
|
|
283
|
+
const writer: RoutineWriterV1 = {
|
|
284
|
+
kind: "bot",
|
|
285
|
+
botId: host.botId,
|
|
286
|
+
sessionId: host.writer.sessionId,
|
|
287
|
+
turnId: host.writer.turnId,
|
|
288
|
+
};
|
|
289
|
+
let receipt: RoutineCommandReceiptV1;
|
|
290
|
+
try {
|
|
291
|
+
receipt = await host.execute(command, writer);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return refusal(error instanceof Error ? error.message : String(error));
|
|
294
|
+
}
|
|
295
|
+
if (receipt.status === "deleted") {
|
|
296
|
+
return {
|
|
297
|
+
content: `Deleted Routine ${receipt.routineId}.`,
|
|
298
|
+
isError: false,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (receipt.status === "fired") {
|
|
302
|
+
return {
|
|
303
|
+
content: [
|
|
304
|
+
`Routine ${receipt.routineId} is queued to fire as run ${receipt.fireId}.`,
|
|
305
|
+
"It runs as its own Turn once this one ends; it does not run inside this conversation.",
|
|
306
|
+
].join(" "),
|
|
307
|
+
isError: false,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const routine = receipt.routine;
|
|
311
|
+
const timing = routine.schedule
|
|
312
|
+
? `schedule ${routine.schedule} (${routine.timezone})`
|
|
313
|
+
: "webhook trigger";
|
|
314
|
+
return {
|
|
315
|
+
content: [
|
|
316
|
+
`Routine "${routine.name}" (${routine.routineId}) is ${
|
|
317
|
+
routine.enabled ? "enabled" : "paused"
|
|
318
|
+
} on ${timing}.`,
|
|
319
|
+
"It is recorded with your provenance and takes effect from your next firing.",
|
|
320
|
+
].join(" "),
|
|
321
|
+
isError: false,
|
|
322
|
+
};
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The runtime Contribution. `routine_manage` declares no `admission`, so it is
|
|
329
|
+
* offered on every turn type its Capability's manifest ceiling allows — it is a
|
|
330
|
+
* work tool, and the Capability names all four turn types.
|
|
331
|
+
*/
|
|
332
|
+
export function createRoutinesRuntimePlugin(
|
|
333
|
+
host: RoutinesRuntimeHostV1,
|
|
334
|
+
): Plugin.Function {
|
|
335
|
+
const plugin: Plugin.Function = (ctx) => {
|
|
336
|
+
const writer = host.writer;
|
|
337
|
+
if (!writer) return () => {};
|
|
338
|
+
const dispose = ctx.tools.register(
|
|
339
|
+
createRoutineManageTool({ ...host, writer }),
|
|
340
|
+
);
|
|
341
|
+
return () => dispose();
|
|
342
|
+
};
|
|
343
|
+
plugin.inject = ["tools"];
|
|
344
|
+
return plugin;
|
|
345
|
+
}
|