@nettee/beacon 0.1.9 → 0.1.11
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 +27 -17
- package/dist/config/profile.js +21 -2
- package/dist/feishu/gateway.js +0 -3
- package/dist/manual-trigger.js +3 -3
- package/dist/outcome/content.js +97 -21
- package/dist/outcome/doctor.js +2 -1
- package/dist/outcome/instructions.js +5 -2
- package/dist/outcome/server.js +9 -10
- package/dist/outcome/submit.js +7 -6
- package/dist/run/orchestrator.js +94 -57
- package/dist/runtime/pi-outcome-extension.js +55 -39
- package/dist/schedule/reconciler.js +8 -1
- package/dist/schedule-trigger.js +5 -1
- package/dist/state/trigger-store.js +26 -3
- package/examples/profiles/example/profile.yaml +4 -2
- package/examples/profiles/example/prompt.md +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Beacon
|
|
2
2
|
|
|
3
|
-
Beacon is a single-host service that turns Feishu messages and configured schedules into isolated Pi agent runs. Each accepted Trigger is durably claimed, produces
|
|
3
|
+
Beacon is a single-host service that turns Feishu messages and configured schedules into isolated Pi agent runs. Each accepted Trigger is durably claimed, produces an explicit Final Outcome, and may `reply` to a person, `notify_card` a group, or stay silent on the admin channel.
|
|
4
4
|
|
|
5
5
|
This repository currently implements the Feishu + Pi MVP described by the
|
|
6
6
|
[Zest Dev Spec](https://github.com/nettee/beacon/blob/main/specs/change/20260913-beacon-feishu-pi-mvp/spec.md).
|
|
@@ -97,7 +97,13 @@ per-Run directories with mode `0700` when Pi starts.
|
|
|
97
97
|
|
|
98
98
|
Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, unsafe Prompt paths, permissive secret or runtime-environment permissions, and missing Profile credentials all fail startup. Beacon validates all Profiles before opening a Feishu connection.
|
|
99
99
|
|
|
100
|
-
Each Schedule uses a five-field cron expression and an IANA timezone.
|
|
100
|
+
Each Schedule uses a five-field cron expression and an IANA timezone. Profile
|
|
101
|
+
`admin.chat_id` is the private chat used for schedule `reply` / `no_reply` and
|
|
102
|
+
for failure notices. Optional `notify.chat_id` on a Schedule is the group that
|
|
103
|
+
receives `notify_card`. Beacon deliberately does not infer or fall back to
|
|
104
|
+
another destination. A newly discovered Schedule starts at the current time.
|
|
105
|
+
After sleep or restart, overdue occurrences are reconciled and coalesced to the
|
|
106
|
+
most recent one.
|
|
101
107
|
|
|
102
108
|
## Commands
|
|
103
109
|
|
|
@@ -129,20 +135,24 @@ beacon schedule trigger --profile example --schedule daily-report
|
|
|
129
135
|
```
|
|
130
136
|
|
|
131
137
|
This creates a distinct durable Run on every invocation and prints its
|
|
132
|
-
`run_id`. It uses the Schedule's configured `input
|
|
133
|
-
does not read, initialize,
|
|
138
|
+
`run_id`. It uses the Schedule's configured `input`, replies to
|
|
139
|
+
`admin.chat_id`, and may notify `notify.chat_id`, but does not read, initialize,
|
|
140
|
+
or advance the Schedule's cron cursor. Unknown
|
|
134
141
|
Profiles or Schedules and failed Runs or Deliveries exit non-zero; failure
|
|
135
142
|
messages include the `run_id` whenever a Run record was created.
|
|
136
143
|
|
|
137
144
|
## Final Outcomes
|
|
138
145
|
|
|
139
|
-
An Agent
|
|
146
|
+
An Agent closes the conversational channel with `reply` or `no_reply`, and may
|
|
147
|
+
also call `notify_card` on the same Run:
|
|
140
148
|
|
|
141
|
-
- `
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
sent
|
|
149
|
+
- `reply` sends plain text. Inbound Feishu messages quote-reply the user.
|
|
150
|
+
Schedules message the Profile admin. Inbound Runs must call `reply`, even
|
|
151
|
+
when the message is outside the Profile's role.
|
|
152
|
+
- `no_reply` finishes a Schedule without messaging the admin. Its `reason` is
|
|
153
|
+
stored for audit and is never sent. Do not use it on inbound messages.
|
|
154
|
+
- `notify_card` posts an interactive card to the Schedule's configured group.
|
|
155
|
+
Inbound messages cannot notify.
|
|
146
156
|
|
|
147
157
|
The card tool accepts a title, Feishu-compatible Markdown body, and up to five
|
|
148
158
|
HTTP(S) link buttons. The first button is styled as primary. The tools accept
|
|
@@ -164,15 +174,15 @@ these argument shapes:
|
|
|
164
174
|
```
|
|
165
175
|
|
|
166
176
|
```json
|
|
167
|
-
{ "reason": "
|
|
177
|
+
{ "reason": "No new models to onboard." }
|
|
168
178
|
```
|
|
169
179
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
a
|
|
175
|
-
Markdown on stdout
|
|
180
|
+
Beacon binds destinations; the Agent never supplies a `chat_id`. Text remains
|
|
181
|
+
text. Cards are sent with Feishu's `interactive` message type and include the
|
|
182
|
+
card generation time. A schedule `no_reply` without `notify_card` records a
|
|
183
|
+
successful Run with no Delivery. Failures `reply` a short error and never
|
|
184
|
+
notify a group. Manual local triggers print reply text and any notify card as
|
|
185
|
+
Markdown on stdout.
|
|
176
186
|
|
|
177
187
|
## State and failure behavior
|
|
178
188
|
|
package/dist/config/profile.js
CHANGED
|
@@ -27,7 +27,10 @@ const scheduleSchema = z
|
|
|
27
27
|
}
|
|
28
28
|
}, "Schedule timezone must be a valid IANA timezone"),
|
|
29
29
|
input: z.string().trim().min(1),
|
|
30
|
-
|
|
30
|
+
notify: z
|
|
31
|
+
.object({ chat_id: z.string().trim().min(1) })
|
|
32
|
+
.strict()
|
|
33
|
+
.optional(),
|
|
31
34
|
})
|
|
32
35
|
.strict();
|
|
33
36
|
const profileDocumentSchema = z
|
|
@@ -41,6 +44,10 @@ const profileDocumentSchema = z
|
|
|
41
44
|
id: z.string().min(1),
|
|
42
45
|
})
|
|
43
46
|
.strict(),
|
|
47
|
+
admin: z
|
|
48
|
+
.object({ chat_id: z.string().trim().min(1) })
|
|
49
|
+
.strict()
|
|
50
|
+
.optional(),
|
|
44
51
|
schedules: z.array(scheduleSchema).default([]),
|
|
45
52
|
})
|
|
46
53
|
.strict();
|
|
@@ -73,6 +80,9 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
|
|
|
73
80
|
});
|
|
74
81
|
}
|
|
75
82
|
}
|
|
83
|
+
if (config.schedules.length > 0 && !config.admin) {
|
|
84
|
+
throw new Error(`Profile ${profileId} with schedules must declare admin.chat_id`);
|
|
85
|
+
}
|
|
76
86
|
if (isAbsolute(config.prompt)) {
|
|
77
87
|
throw new Error(`Profile prompt path must be relative to ${profileDirectory}`);
|
|
78
88
|
}
|
|
@@ -115,12 +125,21 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
|
|
|
115
125
|
workspace,
|
|
116
126
|
runtime: config.runtime,
|
|
117
127
|
model: config.model,
|
|
128
|
+
...(config.admin ? { admin: { chatId: config.admin.chat_id } } : {}),
|
|
118
129
|
schedules: config.schedules.map((schedule) => ({
|
|
119
130
|
id: schedule.id,
|
|
120
131
|
cron: schedule.cron,
|
|
121
132
|
timezone: schedule.timezone,
|
|
122
133
|
input: schedule.input,
|
|
123
|
-
|
|
134
|
+
...(schedule.notify
|
|
135
|
+
? { notify: { chatId: schedule.notify.chat_id } }
|
|
136
|
+
: {}),
|
|
124
137
|
})),
|
|
125
138
|
};
|
|
126
139
|
}
|
|
140
|
+
export function profileAdminChatId(profile) {
|
|
141
|
+
if (!profile.admin?.chatId) {
|
|
142
|
+
throw new Error(`Profile ${profile.id} must declare admin.chat_id`);
|
|
143
|
+
}
|
|
144
|
+
return profile.admin.chatId;
|
|
145
|
+
}
|
package/dist/feishu/gateway.js
CHANGED
|
@@ -22,9 +22,6 @@ function assertSucceeded(operation, result) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
export function encodeFeishuFinalOutcome(outcome) {
|
|
25
|
-
if (outcome.kind === "no_reply") {
|
|
26
|
-
throw new Error("No-reply Outcomes must not be encoded for Delivery");
|
|
27
|
-
}
|
|
28
25
|
if (outcome.kind === "text") {
|
|
29
26
|
return {
|
|
30
27
|
msgType: "text",
|
package/dist/manual-trigger.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { loadGlobalConfig } from "./config/global.js";
|
|
3
3
|
import { loadProfileRegistry } from "./config/registry.js";
|
|
4
4
|
import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
|
|
5
|
-
import {
|
|
5
|
+
import { renderDeliveryAsText } from "./outcome/content.js";
|
|
6
6
|
import { startOutcomeServer } from "./outcome/server.js";
|
|
7
7
|
import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
|
|
8
8
|
import { RunQueue } from "./run/queue.js";
|
|
@@ -34,7 +34,7 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
|
|
|
34
34
|
if (target.kind !== "local_stdout") {
|
|
35
35
|
throw new Error("Manual Trigger requires local stdout Delivery");
|
|
36
36
|
}
|
|
37
|
-
await output(
|
|
37
|
+
await output(renderDeliveryAsText(outcome));
|
|
38
38
|
return {};
|
|
39
39
|
},
|
|
40
40
|
};
|
|
@@ -53,7 +53,7 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
|
|
|
53
53
|
}));
|
|
54
54
|
const record = (await store.list()).find((candidate) => candidate.triggerKey === claim.record.triggerKey);
|
|
55
55
|
if (record?.run?.state !== "succeeded" ||
|
|
56
|
-
(record.finalOutcome?.content.kind !== "no_reply" &&
|
|
56
|
+
(record.finalOutcome?.content.reply.kind !== "no_reply" &&
|
|
57
57
|
record.delivery?.state !== "delivered")) {
|
|
58
58
|
throw new Error(`Manual Trigger failed: ${record?.run?.failure?.code ?? record?.delivery?.failure?.code ?? "unknown"}`);
|
|
59
59
|
}
|
package/dist/outcome/content.js
CHANGED
|
@@ -10,30 +10,106 @@ const buttonSchema = z
|
|
|
10
10
|
.refine((value) => value.startsWith("https://") || value.startsWith("http://"), "must use http or https"),
|
|
11
11
|
})
|
|
12
12
|
.strict();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
const textReplySchema = z
|
|
14
|
+
.object({ kind: z.literal("text"), text: nonBlank })
|
|
15
|
+
.strict();
|
|
16
|
+
const noReplySchema = z
|
|
17
|
+
.object({ kind: z.literal("no_reply"), reason: nonBlank })
|
|
18
|
+
.strict();
|
|
19
|
+
const replySchema = z.discriminatedUnion("kind", [
|
|
20
|
+
textReplySchema,
|
|
21
|
+
noReplySchema,
|
|
22
|
+
]);
|
|
23
|
+
const cardSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
kind: z.literal("card"),
|
|
26
|
+
title: nonBlank,
|
|
27
|
+
content: nonBlank,
|
|
28
|
+
buttons: z.array(buttonSchema).max(5).default([]),
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
export const finalOutcomeContentSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
reply: replySchema,
|
|
34
|
+
notify: cardSchema.optional(),
|
|
35
|
+
})
|
|
36
|
+
.strict();
|
|
37
|
+
const outcomePatchSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
reply: replySchema.optional(),
|
|
40
|
+
notify: cardSchema.optional(),
|
|
41
|
+
})
|
|
42
|
+
.strict()
|
|
43
|
+
.refine((value) => value.reply !== undefined || value.notify !== undefined, {
|
|
44
|
+
message: "Outcome submission must include reply or notify",
|
|
45
|
+
});
|
|
46
|
+
const legacyOutcomeSchema = z.discriminatedUnion("kind", [
|
|
47
|
+
textReplySchema,
|
|
48
|
+
noReplySchema,
|
|
49
|
+
cardSchema,
|
|
24
50
|
]);
|
|
51
|
+
function renderCard(card) {
|
|
52
|
+
return [
|
|
53
|
+
`# ${card.title}`,
|
|
54
|
+
"",
|
|
55
|
+
card.content,
|
|
56
|
+
...card.buttons.map((button) => `\n[${button.label}](${button.url})`),
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
25
59
|
export function parseFinalOutcomeContent(value) {
|
|
60
|
+
const legacy = legacyOutcomeSchema.safeParse(value);
|
|
61
|
+
if (legacy.success) {
|
|
62
|
+
if (legacy.data.kind === "card") {
|
|
63
|
+
return {
|
|
64
|
+
reply: { kind: "text", text: renderCard(legacy.data) },
|
|
65
|
+
notify: legacy.data,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { reply: legacy.data };
|
|
69
|
+
}
|
|
26
70
|
return finalOutcomeContentSchema.parse(value);
|
|
27
71
|
}
|
|
72
|
+
export function parseOutcomePatch(value) {
|
|
73
|
+
const legacy = legacyOutcomeSchema.safeParse(value);
|
|
74
|
+
if (legacy.success)
|
|
75
|
+
return parseFinalOutcomeContent(legacy.data);
|
|
76
|
+
return outcomePatchSchema.parse(value);
|
|
77
|
+
}
|
|
78
|
+
export function mergeOutcome(current, patch) {
|
|
79
|
+
if (patch.reply && current?.reply) {
|
|
80
|
+
throw new Error("Reply already submitted");
|
|
81
|
+
}
|
|
82
|
+
if (patch.notify && current?.notify) {
|
|
83
|
+
throw new Error("Notify already submitted");
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
...(current?.reply || patch.reply
|
|
87
|
+
? { reply: patch.reply ?? current?.reply }
|
|
88
|
+
: {}),
|
|
89
|
+
...(current?.notify || patch.notify
|
|
90
|
+
? { notify: patch.notify ?? current?.notify }
|
|
91
|
+
: {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function completeOutcome(partial) {
|
|
95
|
+
if (!partial.reply) {
|
|
96
|
+
throw new Error("Agent Runtime settled without submitting reply or no_reply");
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
reply: partial.reply,
|
|
100
|
+
...(partial.notify ? { notify: partial.notify } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
28
103
|
export function renderFinalOutcomeAsText(outcome) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
104
|
+
const parts = [];
|
|
105
|
+
if (outcome.reply.kind === "text")
|
|
106
|
+
parts.push(outcome.reply.text);
|
|
107
|
+
if (outcome.notify)
|
|
108
|
+
parts.push(renderCard(outcome.notify));
|
|
109
|
+
return parts.join("\n\n");
|
|
110
|
+
}
|
|
111
|
+
export function renderDeliveryAsText(content) {
|
|
112
|
+
if (content.kind === "text")
|
|
113
|
+
return content.text;
|
|
114
|
+
return renderCard(content);
|
|
39
115
|
}
|
package/dist/outcome/doctor.js
CHANGED
|
@@ -18,7 +18,8 @@ export async function runOutcomeDoctor(profileId) {
|
|
|
18
18
|
const beaconCliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
|
|
19
19
|
try {
|
|
20
20
|
const result = await createProfileRunner(profile, outcomes, beaconCliPath)(doctorTrigger);
|
|
21
|
-
if (result.kind !== "text" ||
|
|
21
|
+
if (result.reply.kind !== "text" ||
|
|
22
|
+
result.reply.text.trim() !== "OUTCOME_CLI_OK") {
|
|
22
23
|
throw new Error(`Outcome doctor received unexpected submitted text: ${JSON.stringify(result)}`);
|
|
23
24
|
}
|
|
24
25
|
console.log("[beacon] explicit Final Outcome chain ready");
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
export const inboundOutOfRoleReply = "这条消息不在我的职责范围内。";
|
|
1
2
|
export const finalOutcomeSystemInstructions = [
|
|
2
|
-
"When your work is complete,
|
|
3
|
-
"
|
|
3
|
+
"When your work is complete, close the conversational channel with exactly one of `reply` or `no_reply`. You may also call `notify_card` at most once on the same Run.",
|
|
4
|
+
"`reply` sends plain text. For an inbound Feishu message, quote-reply that message. For a Schedule, message the Profile admin. Inbound Runs must call `reply`, even when the message is outside this Profile's role: send a short text saying the request is out of scope. Do not call `no_reply` on inbound messages.",
|
|
5
|
+
"`no_reply` finishes a Schedule without messaging the admin. Its reason is stored for audit and is never sent. Do not use it for inbound Feishu messages.",
|
|
6
|
+
"`notify_card` posts a titled Markdown card to the Schedule's configured group. Call it only when this Run is a Schedule with a notify target. Inbound messages cannot notify.",
|
|
4
7
|
"Beacon ignores ordinary assistant final text for Delivery.",
|
|
5
8
|
];
|
package/dist/outcome/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { createServer } from "node:net";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { completeOutcome, mergeOutcome, parseOutcomePatch, } from "./content.js";
|
|
7
7
|
function respond(socket, response) {
|
|
8
8
|
socket.end(`${JSON.stringify(response)}\n`);
|
|
9
9
|
}
|
|
@@ -54,9 +54,9 @@ export async function startOutcomeServer(options = {}) {
|
|
|
54
54
|
});
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
|
-
let
|
|
57
|
+
let patch;
|
|
58
58
|
try {
|
|
59
|
-
|
|
59
|
+
patch = parseOutcomePatch(request.outcome);
|
|
60
60
|
}
|
|
61
61
|
catch (error) {
|
|
62
62
|
respond(socket, {
|
|
@@ -70,14 +70,16 @@ export async function startOutcomeServer(options = {}) {
|
|
|
70
70
|
respond(socket, { ok: false, error: "unknown Run Capability" });
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
73
|
-
|
|
73
|
+
try {
|
|
74
|
+
submission.outcome = mergeOutcome(submission.outcome, patch);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
74
77
|
respond(socket, {
|
|
75
78
|
ok: false,
|
|
76
|
-
error:
|
|
79
|
+
error: error instanceof Error ? error.message : String(error),
|
|
77
80
|
});
|
|
78
81
|
return;
|
|
79
82
|
}
|
|
80
|
-
submission.outcome = outcome;
|
|
81
83
|
console.log("[beacon] Final Outcome submitted by Agent Runtime");
|
|
82
84
|
respond(socket, { ok: true });
|
|
83
85
|
});
|
|
@@ -98,10 +100,7 @@ export async function startOutcomeServer(options = {}) {
|
|
|
98
100
|
binding: { socketPath, runToken },
|
|
99
101
|
take() {
|
|
100
102
|
submissions.delete(runToken);
|
|
101
|
-
|
|
102
|
-
throw new Error("Agent Runtime settled without submitting a Final Outcome");
|
|
103
|
-
}
|
|
104
|
-
return record.outcome;
|
|
103
|
+
return completeOutcome(record.outcome ?? {});
|
|
105
104
|
},
|
|
106
105
|
cancel() {
|
|
107
106
|
submissions.delete(runToken);
|
package/dist/outcome/submit.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createConnection } from "node:net";
|
|
2
|
-
import {
|
|
2
|
+
import { completeOutcome, mergeOutcome, parseOutcomePatch, } from "./content.js";
|
|
3
3
|
export async function submitOutcome(socketPath, runToken, outcome) {
|
|
4
|
-
const
|
|
4
|
+
const patch = parseOutcomePatch(outcome);
|
|
5
5
|
await new Promise((resolve, reject) => {
|
|
6
6
|
const socket = createConnection(socketPath);
|
|
7
7
|
socket.setEncoding("utf8");
|
|
8
8
|
let responseBuffer = "";
|
|
9
9
|
socket.once("connect", () => {
|
|
10
|
-
socket.write(`${JSON.stringify({ runToken, outcome:
|
|
10
|
+
socket.write(`${JSON.stringify({ runToken, outcome: patch })}\n`);
|
|
11
11
|
});
|
|
12
12
|
socket.on("data", (chunk) => {
|
|
13
13
|
responseBuffer += chunk;
|
|
@@ -42,15 +42,16 @@ export async function submitOutcomeFromCli() {
|
|
|
42
42
|
let input = "";
|
|
43
43
|
for await (const chunk of process.stdin)
|
|
44
44
|
input += chunk;
|
|
45
|
-
let
|
|
45
|
+
let patch;
|
|
46
46
|
try {
|
|
47
|
-
|
|
47
|
+
patch = parseOutcomePatch(JSON.parse(input));
|
|
48
48
|
}
|
|
49
49
|
catch (error) {
|
|
50
50
|
throw new Error("Final Outcome stdin must be valid outcome JSON", {
|
|
51
51
|
cause: error,
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
|
-
await submitOutcome(socketPath, runToken,
|
|
54
|
+
await submitOutcome(socketPath, runToken, patch);
|
|
55
55
|
console.log("Final Outcome accepted by Beacon");
|
|
56
56
|
}
|
|
57
|
+
export { completeOutcome, mergeOutcome };
|
package/dist/run/orchestrator.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { inboundOutOfRoleReply } from "../outcome/instructions.js";
|
|
3
4
|
import { PiRuntimeError } from "../runtime/pi-rpc.js";
|
|
4
5
|
import { buildAgentSystemPrompt } from "../runtime/system-prompt.js";
|
|
5
6
|
function promptFor(input) {
|
|
@@ -38,6 +39,26 @@ function summary(error) {
|
|
|
38
39
|
const value = error instanceof Error ? error.message : String(error);
|
|
39
40
|
return value.slice(0, 4096) || "unknown failure";
|
|
40
41
|
}
|
|
42
|
+
function failureOutcome(runId) {
|
|
43
|
+
return {
|
|
44
|
+
reply: {
|
|
45
|
+
kind: "text",
|
|
46
|
+
text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function normalizeAgentOutcome(input, outcome, notifyTarget) {
|
|
51
|
+
if (input.kind !== "schedule" && outcome.notify) {
|
|
52
|
+
throw new Error("notify_card is only valid on Schedule Runs");
|
|
53
|
+
}
|
|
54
|
+
if (outcome.notify && !notifyTarget) {
|
|
55
|
+
throw new Error("notify_card requires a configured notify chat");
|
|
56
|
+
}
|
|
57
|
+
if (input.kind === "feishu_message" && outcome.reply.kind === "no_reply") {
|
|
58
|
+
return { reply: { kind: "text", text: inboundOutOfRoleReply } };
|
|
59
|
+
}
|
|
60
|
+
return outcome;
|
|
61
|
+
}
|
|
41
62
|
export class RunOrchestrator {
|
|
42
63
|
options;
|
|
43
64
|
now;
|
|
@@ -53,6 +74,12 @@ export class RunOrchestrator {
|
|
|
53
74
|
timestamp() {
|
|
54
75
|
return this.now().toISOString();
|
|
55
76
|
}
|
|
77
|
+
async record(triggerKey) {
|
|
78
|
+
const found = (await this.options.store.list()).find((candidate) => candidate.triggerKey === triggerKey);
|
|
79
|
+
if (!found)
|
|
80
|
+
throw new Error(`Unknown Trigger ${triggerKey}`);
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
56
83
|
newRun(runId, state, failure) {
|
|
57
84
|
const timestamp = this.timestamp();
|
|
58
85
|
return {
|
|
@@ -71,39 +98,39 @@ export class RunOrchestrator {
|
|
|
71
98
|
...(failure ? { failure } : {}),
|
|
72
99
|
};
|
|
73
100
|
}
|
|
74
|
-
async
|
|
101
|
+
async deliverField(triggerKey, field, target, content, create = true) {
|
|
75
102
|
const deliveryId = `del_${this.id()}`;
|
|
76
|
-
let record;
|
|
77
103
|
if (create) {
|
|
78
|
-
|
|
104
|
+
await this.options.store.update(triggerKey, (current) => ({
|
|
79
105
|
...current,
|
|
80
|
-
|
|
106
|
+
[field]: {
|
|
81
107
|
deliveryId,
|
|
82
|
-
target
|
|
108
|
+
target,
|
|
83
109
|
state: "pending",
|
|
84
110
|
},
|
|
85
111
|
}));
|
|
86
112
|
}
|
|
87
113
|
else {
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
114
|
+
const current = await this.record(triggerKey);
|
|
115
|
+
if (current[field]?.state !== "pending") {
|
|
116
|
+
return;
|
|
91
117
|
}
|
|
92
118
|
}
|
|
93
|
-
|
|
119
|
+
await this.options.store.update(triggerKey, (current) => ({
|
|
94
120
|
...current,
|
|
95
|
-
|
|
96
|
-
...current
|
|
121
|
+
[field]: {
|
|
122
|
+
...current[field],
|
|
97
123
|
state: "delivering",
|
|
98
124
|
startedAt: this.timestamp(),
|
|
99
125
|
},
|
|
100
126
|
}));
|
|
127
|
+
const live = await this.record(triggerKey);
|
|
101
128
|
try {
|
|
102
|
-
const result = await this.options.delivery.deliver(
|
|
129
|
+
const result = await this.options.delivery.deliver(target, content, live[field].deliveryId);
|
|
103
130
|
await this.options.store.update(triggerKey, (current) => ({
|
|
104
131
|
...current,
|
|
105
|
-
|
|
106
|
-
...current
|
|
132
|
+
[field]: {
|
|
133
|
+
...current[field],
|
|
107
134
|
state: "delivered",
|
|
108
135
|
finishedAt: this.timestamp(),
|
|
109
136
|
...(result.providerRequestId
|
|
@@ -115,8 +142,8 @@ export class RunOrchestrator {
|
|
|
115
142
|
catch (error) {
|
|
116
143
|
await this.options.store.update(triggerKey, (current) => ({
|
|
117
144
|
...current,
|
|
118
|
-
|
|
119
|
-
...current
|
|
145
|
+
[field]: {
|
|
146
|
+
...current[field],
|
|
120
147
|
state: "failed",
|
|
121
148
|
finishedAt: this.timestamp(),
|
|
122
149
|
failure: { code: "delivery_api_failed", summary: summary(error) },
|
|
@@ -124,6 +151,16 @@ export class RunOrchestrator {
|
|
|
124
151
|
}));
|
|
125
152
|
}
|
|
126
153
|
}
|
|
154
|
+
async deliverOutcome(triggerKey, create = true) {
|
|
155
|
+
const current = await this.record(triggerKey);
|
|
156
|
+
const outcome = current.finalOutcome.content;
|
|
157
|
+
if (outcome.reply.kind === "text") {
|
|
158
|
+
await this.deliverField(triggerKey, "delivery", current.target, outcome.reply, create && current.delivery === undefined);
|
|
159
|
+
}
|
|
160
|
+
if (outcome.notify && current.notifyTarget) {
|
|
161
|
+
await this.deliverField(triggerKey, "notifyDelivery", current.notifyTarget, outcome.notify, create && current.notifyDelivery === undefined);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
127
164
|
async fail(triggerKey, runId, code, error) {
|
|
128
165
|
const detail = summary(error);
|
|
129
166
|
await this.options.store.update(triggerKey, (current) => ({
|
|
@@ -136,14 +173,11 @@ export class RunOrchestrator {
|
|
|
136
173
|
},
|
|
137
174
|
finalOutcome: {
|
|
138
175
|
origin: "beacon_failure",
|
|
139
|
-
content:
|
|
140
|
-
kind: "text",
|
|
141
|
-
text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
|
|
142
|
-
},
|
|
176
|
+
content: failureOutcome(runId),
|
|
143
177
|
submittedAt: this.timestamp(),
|
|
144
178
|
},
|
|
145
179
|
}));
|
|
146
|
-
await this.
|
|
180
|
+
await this.deliverOutcome(triggerKey);
|
|
147
181
|
}
|
|
148
182
|
async execute(triggerKey, input, runId) {
|
|
149
183
|
const record = await this.options.store.update(triggerKey, (current) => {
|
|
@@ -156,7 +190,6 @@ export class RunOrchestrator {
|
|
|
156
190
|
sessionPath: current.run.sessionPath,
|
|
157
191
|
}
|
|
158
192
|
: {
|
|
159
|
-
// Migration for queued records written by Beacon <= 0.1.2.
|
|
160
193
|
sessionId: current.run.runId,
|
|
161
194
|
sessionPath: join(this.options.sessionDirectory, this.options.profile.id, current.run.runId),
|
|
162
195
|
};
|
|
@@ -189,7 +222,7 @@ export class RunOrchestrator {
|
|
|
189
222
|
name: `Beacon ${this.options.profile.id} ${runId}`,
|
|
190
223
|
},
|
|
191
224
|
});
|
|
192
|
-
const outcome = submission.take();
|
|
225
|
+
const outcome = normalizeAgentOutcome(input, submission.take(), record.notifyTarget);
|
|
193
226
|
await this.options.store.update(triggerKey, (current) => ({
|
|
194
227
|
...current,
|
|
195
228
|
run: {
|
|
@@ -205,15 +238,14 @@ export class RunOrchestrator {
|
|
|
205
238
|
submittedAt: this.timestamp(),
|
|
206
239
|
},
|
|
207
240
|
}));
|
|
208
|
-
|
|
209
|
-
await this.deliver(triggerKey);
|
|
241
|
+
await this.deliverOutcome(triggerKey);
|
|
210
242
|
}
|
|
211
243
|
catch (error) {
|
|
212
244
|
submission.cancel();
|
|
213
245
|
const code = error instanceof PiRuntimeError
|
|
214
246
|
? error.code
|
|
215
247
|
: error instanceof Error &&
|
|
216
|
-
/submitting a Final Outcome/.test(error.message)
|
|
248
|
+
/submitting a (Final Outcome|reply or no_reply)/.test(error.message)
|
|
217
249
|
? "outcome_missing"
|
|
218
250
|
: "runtime_exit_failed";
|
|
219
251
|
await this.fail(triggerKey, runId, code, error);
|
|
@@ -234,14 +266,11 @@ export class RunOrchestrator {
|
|
|
234
266
|
}),
|
|
235
267
|
finalOutcome: {
|
|
236
268
|
origin: "beacon_failure",
|
|
237
|
-
content:
|
|
238
|
-
kind: "text",
|
|
239
|
-
text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
|
|
240
|
-
},
|
|
269
|
+
content: failureOutcome(runId),
|
|
241
270
|
submittedAt: this.timestamp(),
|
|
242
271
|
},
|
|
243
272
|
}));
|
|
244
|
-
await this.
|
|
273
|
+
await this.deliverOutcome(triggerKey);
|
|
245
274
|
return;
|
|
246
275
|
}
|
|
247
276
|
await this.options.store.update(triggerKey, (current) => ({
|
|
@@ -258,46 +287,54 @@ export class RunOrchestrator {
|
|
|
258
287
|
}
|
|
259
288
|
async recover() {
|
|
260
289
|
for (const record of await this.options.store.list()) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
290
|
+
for (const field of ["delivery", "notifyDelivery"]) {
|
|
291
|
+
if (record[field]?.state === "delivering") {
|
|
292
|
+
await this.options.store.update(record.triggerKey, (current) => ({
|
|
293
|
+
...current,
|
|
294
|
+
[field]: {
|
|
295
|
+
...current[field],
|
|
296
|
+
state: "failed",
|
|
297
|
+
finishedAt: this.timestamp(),
|
|
298
|
+
failure: {
|
|
299
|
+
code: "delivery_interrupted",
|
|
300
|
+
summary: "Service restarted while Delivery result was unknown",
|
|
301
|
+
},
|
|
271
302
|
},
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
continue;
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
275
305
|
}
|
|
276
|
-
|
|
277
|
-
|
|
306
|
+
const latest = await this.record(record.triggerKey);
|
|
307
|
+
if (latest.delivery?.state === "pending" ||
|
|
308
|
+
latest.notifyDelivery?.state === "pending") {
|
|
309
|
+
await this.deliverOutcome(latest.triggerKey, false);
|
|
278
310
|
continue;
|
|
279
311
|
}
|
|
280
|
-
if (
|
|
281
|
-
await this.fail(
|
|
312
|
+
if (latest.run?.state === "starting" || latest.run?.state === "running") {
|
|
313
|
+
await this.fail(latest.triggerKey, latest.run.runId, "service_interrupted", "Service restarted while Run was active");
|
|
282
314
|
continue;
|
|
283
315
|
}
|
|
284
|
-
if (
|
|
285
|
-
const queued = this.options.queue.enqueue(() => this.execute(
|
|
316
|
+
if (latest.run?.state === "queued" && latest.input) {
|
|
317
|
+
const queued = this.options.queue.enqueue(() => this.execute(latest.triggerKey, latest.input, latest.run.runId));
|
|
286
318
|
if (!queued.accepted) {
|
|
287
319
|
throw new Error("Persisted queued Runs exceed configured queue capacity");
|
|
288
320
|
}
|
|
289
321
|
await queued.completion;
|
|
290
322
|
continue;
|
|
291
323
|
}
|
|
292
|
-
if (!
|
|
293
|
-
await this.fail(
|
|
324
|
+
if (!latest.run) {
|
|
325
|
+
await this.fail(latest.triggerKey, `run_${this.id()}`, "service_interrupted", "Service restarted before Trigger normalization completed");
|
|
294
326
|
continue;
|
|
295
327
|
}
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
328
|
+
if (latest.finalOutcome &&
|
|
329
|
+
(latest.run.state === "succeeded" || latest.run.state === "failed")) {
|
|
330
|
+
const outcome = latest.finalOutcome.content;
|
|
331
|
+
const missingReply = outcome.reply.kind === "text" && !latest.delivery;
|
|
332
|
+
const missingNotify = Boolean(outcome.notify) &&
|
|
333
|
+
Boolean(latest.notifyTarget) &&
|
|
334
|
+
!latest.notifyDelivery;
|
|
335
|
+
if (missingReply || missingNotify) {
|
|
336
|
+
await this.deliverOutcome(latest.triggerKey);
|
|
337
|
+
}
|
|
301
338
|
}
|
|
302
339
|
}
|
|
303
340
|
}
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
export function
|
|
4
|
-
return
|
|
2
|
+
import { parseOutcomePatch } from "../outcome/content.js";
|
|
3
|
+
export function replyFromToolParams(params) {
|
|
4
|
+
return parseOutcomePatch({
|
|
5
|
+
reply: { kind: "text", text: params.text },
|
|
6
|
+
});
|
|
5
7
|
}
|
|
6
|
-
export function
|
|
7
|
-
return
|
|
8
|
-
kind: "
|
|
9
|
-
title: params.title,
|
|
10
|
-
content: params.content,
|
|
11
|
-
buttons: params.buttons ?? [],
|
|
8
|
+
export function noReplyFromToolParams(params) {
|
|
9
|
+
return parseOutcomePatch({
|
|
10
|
+
reply: { kind: "no_reply", reason: params.reason },
|
|
12
11
|
});
|
|
13
12
|
}
|
|
14
|
-
export function
|
|
15
|
-
|
|
13
|
+
export function notifyCardFromToolParams(params) {
|
|
14
|
+
const patch = parseOutcomePatch({
|
|
15
|
+
notify: {
|
|
16
|
+
kind: "card",
|
|
17
|
+
title: params.title,
|
|
18
|
+
content: params.content,
|
|
19
|
+
buttons: params.buttons ?? [],
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (!patch.notify)
|
|
23
|
+
throw new Error("notify_card produced no card");
|
|
24
|
+
return patch.notify;
|
|
16
25
|
}
|
|
17
26
|
async function invokeBeaconCli(outcome, signal) {
|
|
18
27
|
const cliPath = process.env.BEACON_CLI_PATH;
|
|
@@ -48,9 +57,9 @@ function acceptedResult() {
|
|
|
48
57
|
}
|
|
49
58
|
export default function registerOutcomeTools(pi) {
|
|
50
59
|
pi.registerTool({
|
|
51
|
-
name: "
|
|
52
|
-
label: "
|
|
53
|
-
description: "
|
|
60
|
+
name: "reply",
|
|
61
|
+
label: "Reply",
|
|
62
|
+
description: "Send a plain-text reply. Inbound Feishu messages quote-reply the user. Schedules message the admin. Inbound Runs must use this tool, including out-of-role messages.",
|
|
54
63
|
parameters: {
|
|
55
64
|
type: "object",
|
|
56
65
|
properties: {
|
|
@@ -64,14 +73,35 @@ export default function registerOutcomeTools(pi) {
|
|
|
64
73
|
additionalProperties: false,
|
|
65
74
|
},
|
|
66
75
|
async execute(_toolCallId, params, signal) {
|
|
67
|
-
await invokeBeaconCli(
|
|
76
|
+
await invokeBeaconCli({ reply: { kind: "text", text: params.text } }, signal);
|
|
77
|
+
return acceptedResult();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
pi.registerTool({
|
|
81
|
+
name: "no_reply",
|
|
82
|
+
label: "No Reply",
|
|
83
|
+
description: "Finish a Schedule without messaging the admin. Do not use this on inbound Feishu messages.",
|
|
84
|
+
parameters: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
reason: {
|
|
88
|
+
type: "string",
|
|
89
|
+
minLength: 1,
|
|
90
|
+
description: "A concise internal reason why the admin should not be messaged.",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
required: ["reason"],
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
},
|
|
96
|
+
async execute(_toolCallId, params, signal) {
|
|
97
|
+
await invokeBeaconCli({ reply: { kind: "no_reply", reason: params.reason } }, signal);
|
|
68
98
|
return acceptedResult();
|
|
69
99
|
},
|
|
70
100
|
});
|
|
71
101
|
pi.registerTool({
|
|
72
|
-
name: "
|
|
73
|
-
label: "
|
|
74
|
-
description: "
|
|
102
|
+
name: "notify_card",
|
|
103
|
+
label: "Notify Card",
|
|
104
|
+
description: "Post a structured Feishu card to the Schedule's configured notify group. Inbound messages cannot notify. You must still call reply or no_reply on the same Run.",
|
|
75
105
|
parameters: {
|
|
76
106
|
type: "object",
|
|
77
107
|
properties: {
|
|
@@ -103,28 +133,14 @@ export default function registerOutcomeTools(pi) {
|
|
|
103
133
|
additionalProperties: false,
|
|
104
134
|
},
|
|
105
135
|
async execute(_toolCallId, params, signal) {
|
|
106
|
-
await invokeBeaconCli(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
label: "Submit No Reply Outcome",
|
|
113
|
-
description: "Complete the run without sending a reply. Use this exactly once when the triggering message is outside the Profile's role.",
|
|
114
|
-
parameters: {
|
|
115
|
-
type: "object",
|
|
116
|
-
properties: {
|
|
117
|
-
reason: {
|
|
118
|
-
type: "string",
|
|
119
|
-
minLength: 1,
|
|
120
|
-
description: "A concise internal reason why the message should not receive a reply.",
|
|
136
|
+
await invokeBeaconCli({
|
|
137
|
+
notify: {
|
|
138
|
+
kind: "card",
|
|
139
|
+
title: params.title,
|
|
140
|
+
content: params.content,
|
|
141
|
+
buttons: params.buttons ?? [],
|
|
121
142
|
},
|
|
122
|
-
},
|
|
123
|
-
required: ["reason"],
|
|
124
|
-
additionalProperties: false,
|
|
125
|
-
},
|
|
126
|
-
async execute(_toolCallId, params, signal) {
|
|
127
|
-
await invokeBeaconCli(noReplyOutcomeFromToolParams(params), signal);
|
|
143
|
+
}, signal);
|
|
128
144
|
return acceptedResult();
|
|
129
145
|
},
|
|
130
146
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { profileAdminChatId } from "../config/profile.js";
|
|
1
2
|
import { occurrencesBetween } from "./cron.js";
|
|
2
3
|
export class ScheduleReconciler {
|
|
3
4
|
options;
|
|
@@ -18,7 +19,13 @@ export class ScheduleReconciler {
|
|
|
18
19
|
const scheduledFor = latest.toISOString();
|
|
19
20
|
const claim = await this.options.triggers.claim({
|
|
20
21
|
sourceKey: ["schedule", schedule.id, scheduledFor],
|
|
21
|
-
target: {
|
|
22
|
+
target: {
|
|
23
|
+
kind: "chat",
|
|
24
|
+
chatId: profileAdminChatId(this.options.profile),
|
|
25
|
+
},
|
|
26
|
+
...(schedule.notify
|
|
27
|
+
? { notifyTarget: { kind: "chat", chatId: schedule.notify.chatId } }
|
|
28
|
+
: {}),
|
|
22
29
|
ingress: {
|
|
23
30
|
scheduleId: schedule.id,
|
|
24
31
|
scheduledFor,
|
package/dist/schedule-trigger.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { loadGlobalConfig } from "./config/global.js";
|
|
3
|
+
import { profileAdminChatId } from "./config/profile.js";
|
|
3
4
|
import { loadProfileRegistry } from "./config/registry.js";
|
|
4
5
|
import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
|
|
5
6
|
import { loadFeishuCredentials } from "./config/secrets.js";
|
|
@@ -21,7 +22,10 @@ export async function triggerScheduleOnce(options) {
|
|
|
21
22
|
const invocationId = (options.id ?? randomUUID)();
|
|
22
23
|
const claim = await options.store.claim({
|
|
23
24
|
sourceKey: ["manual-schedule", schedule.id, invocationId],
|
|
24
|
-
target: { kind: "chat", chatId:
|
|
25
|
+
target: { kind: "chat", chatId: profileAdminChatId(options.profile) },
|
|
26
|
+
...(schedule.notify
|
|
27
|
+
? { notifyTarget: { kind: "chat", chatId: schedule.notify.chatId } }
|
|
28
|
+
: {}),
|
|
25
29
|
ingress: {
|
|
26
30
|
invocation: "manual",
|
|
27
31
|
scheduleId: schedule.id,
|
|
@@ -3,7 +3,7 @@ import { chmod, mkdir, open, readdir, readFile, rename, } from "node:fs/promises
|
|
|
3
3
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { failureCodes, } from "../domain/types.js";
|
|
6
|
-
import { finalOutcomeContentSchema } from "../outcome/content.js";
|
|
6
|
+
import { finalOutcomeContentSchema, parseFinalOutcomeContent, } from "../outcome/content.js";
|
|
7
7
|
const timestamp = z.string().datetime({ offset: true });
|
|
8
8
|
const failureSchema = z
|
|
9
9
|
.object({ code: z.enum(failureCodes), summary: z.string().min(1).max(4096) })
|
|
@@ -69,7 +69,14 @@ const outcomeSchema = z.union([
|
|
|
69
69
|
z
|
|
70
70
|
.object({
|
|
71
71
|
origin: z.enum(["agent", "beacon_failure"]),
|
|
72
|
-
content:
|
|
72
|
+
content: z.preprocess((value) => {
|
|
73
|
+
try {
|
|
74
|
+
return parseFinalOutcomeContent(value);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
}, finalOutcomeContentSchema),
|
|
73
80
|
submittedAt: timestamp,
|
|
74
81
|
})
|
|
75
82
|
.strict(),
|
|
@@ -82,7 +89,7 @@ const outcomeSchema = z.union([
|
|
|
82
89
|
.strict()
|
|
83
90
|
.transform(({ origin, text, submittedAt }) => ({
|
|
84
91
|
origin,
|
|
85
|
-
content: { kind: "text", text },
|
|
92
|
+
content: { reply: { kind: "text", text } },
|
|
86
93
|
submittedAt,
|
|
87
94
|
})),
|
|
88
95
|
]);
|
|
@@ -106,11 +113,13 @@ const triggerRecordSchema = z
|
|
|
106
113
|
sourceKey: z.array(z.string().min(1)).min(2),
|
|
107
114
|
acceptedAt: timestamp,
|
|
108
115
|
target: targetSchema,
|
|
116
|
+
notifyTarget: targetSchema.optional(),
|
|
109
117
|
input: inputSchema.optional(),
|
|
110
118
|
ingress: z.record(z.string(), z.unknown()).optional(),
|
|
111
119
|
run: runSchema.optional(),
|
|
112
120
|
finalOutcome: outcomeSchema.optional(),
|
|
113
121
|
delivery: deliverySchema.optional(),
|
|
122
|
+
notifyDelivery: deliverySchema.optional(),
|
|
114
123
|
})
|
|
115
124
|
.strict();
|
|
116
125
|
function keyFor(profileId, sourceKey) {
|
|
@@ -134,6 +143,19 @@ function validateRecord(value) {
|
|
|
134
143
|
if (record.delivery && !record.finalOutcome) {
|
|
135
144
|
throw new Error("Trigger record with Delivery must contain a Final Outcome");
|
|
136
145
|
}
|
|
146
|
+
if (record.notifyDelivery && !record.finalOutcome) {
|
|
147
|
+
throw new Error("Trigger record with notify Delivery must contain a Final Outcome");
|
|
148
|
+
}
|
|
149
|
+
if (record.delivery &&
|
|
150
|
+
record.finalOutcome?.content.reply.kind === "no_reply") {
|
|
151
|
+
throw new Error("no_reply Outcomes must not have a reply Delivery");
|
|
152
|
+
}
|
|
153
|
+
if (record.notifyDelivery && !record.finalOutcome?.content.notify) {
|
|
154
|
+
throw new Error("notify Delivery requires a notify card");
|
|
155
|
+
}
|
|
156
|
+
if (record.notifyDelivery && !record.notifyTarget) {
|
|
157
|
+
throw new Error("notify Delivery requires a notify target");
|
|
158
|
+
}
|
|
137
159
|
if (record.run?.state === "succeeded" &&
|
|
138
160
|
record.finalOutcome?.origin !== "agent") {
|
|
139
161
|
throw new Error("Succeeded Run must contain an Agent Final Outcome");
|
|
@@ -243,6 +265,7 @@ export class TriggerStore {
|
|
|
243
265
|
sourceKey: [...request.sourceKey],
|
|
244
266
|
acceptedAt: (request.acceptedAt ?? new Date()).toISOString(),
|
|
245
267
|
target: request.target,
|
|
268
|
+
...(request.notifyTarget ? { notifyTarget: request.notifyTarget } : {}),
|
|
246
269
|
...(request.ingress ? { ingress: request.ingress } : {}),
|
|
247
270
|
};
|
|
248
271
|
await this.write(triggerKey, record);
|
|
@@ -4,10 +4,12 @@ runtime: pi
|
|
|
4
4
|
model:
|
|
5
5
|
provider: REPLACE_WITH_PI_PROVIDER
|
|
6
6
|
id: REPLACE_WITH_PI_MODEL
|
|
7
|
+
admin:
|
|
8
|
+
chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
|
|
7
9
|
schedules:
|
|
8
10
|
- id: weekday-brief
|
|
9
11
|
cron: "0 9 * * 1-5"
|
|
10
12
|
timezone: Asia/Shanghai
|
|
11
13
|
input: Prepare the weekday brief.
|
|
12
|
-
|
|
13
|
-
chat_id:
|
|
14
|
+
notify:
|
|
15
|
+
chat_id: REPLACE_WITH_GROUP_CHAT_ID
|
|
@@ -2,6 +2,8 @@ You are a workspace-status assistant. Your only role is to answer requests about
|
|
|
2
2
|
|
|
3
3
|
Before reading files or running commands, decide whether the input is within that role. A group `@ All` does not by itself address this bot, and a direct message is not automatically relevant. Use the quoted-message chain when the current message is a follow-up.
|
|
4
4
|
|
|
5
|
-
If
|
|
5
|
+
If a Feishu message is outside this role, call `reply` with a short text saying the request is out of scope. Do not inspect the workspace. Do not call `no_reply` on inbound messages.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Scheduled Runs that have nothing to report should call `no_reply`. If a Schedule has a notify group and there is a report for that group, call `notify_card` and then `no_reply` unless the admin also needs a text update.
|
|
8
|
+
|
|
9
|
+
For in-role work, stay inside the configured workspace and call `reply` with a concise user-facing result. Required operations must fail visibly; do not fabricate fallback data or report success after incomplete work.
|