@nettee/beacon 0.1.3 → 0.1.5
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 +63 -5
- package/dist/cli-app.js +56 -23
- package/dist/cli.js +2 -0
- package/dist/feishu/final-outcome-card.js +53 -0
- package/dist/feishu/gateway.js +18 -7
- package/dist/manual-trigger.js +3 -2
- package/dist/outcome/content.js +36 -0
- package/dist/outcome/doctor.js +1 -1
- package/dist/outcome/instructions.js +5 -0
- package/dist/outcome/server.js +19 -9
- package/dist/outcome/submit.js +16 -7
- package/dist/run/orchestrator.js +12 -6
- package/dist/run/profile-runner.js +2 -2
- package/dist/runtime/pi-outcome-extension.js +66 -12
- package/dist/schedule-trigger.js +81 -0
- package/dist/state/trigger-store.js +22 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,10 +31,11 @@ Upgrade to the current stable release with the same `latest` command. Run
|
|
|
31
31
|
service. Beacon does not manage launchd installation, restart, or rollback in
|
|
32
32
|
this release.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Operator commands find the standard Beacon home automatically; a service
|
|
35
|
+
deployment names its configuration explicitly:
|
|
35
36
|
|
|
36
37
|
```sh
|
|
37
|
-
beacon doctor
|
|
38
|
+
beacon doctor
|
|
38
39
|
beacon serve --config /Users/USERNAME/.beacon/config.yaml
|
|
39
40
|
```
|
|
40
41
|
|
|
@@ -83,18 +84,69 @@ Each Schedule uses a five-field cron expression and an IANA timezone. Its `deliv
|
|
|
83
84
|
|
|
84
85
|
```text
|
|
85
86
|
beacon serve --config <absolute-path>
|
|
86
|
-
beacon doctor --config <absolute-path>
|
|
87
|
-
beacon trigger --config <absolute-path> --profile <id> --input -
|
|
87
|
+
beacon doctor [--config <absolute-path>]
|
|
88
|
+
beacon trigger [--config <absolute-path>] --profile <id> --input -
|
|
89
|
+
beacon schedule trigger [--config <absolute-path>] --profile <id> --schedule <id>
|
|
88
90
|
beacon version
|
|
89
91
|
```
|
|
90
92
|
|
|
93
|
+
Operator commands use `~/.beacon/config.yaml` when `--config` is omitted.
|
|
94
|
+
An explicit override must still be an absolute path. `serve` keeps requiring an
|
|
95
|
+
explicit absolute config path so service definitions identify their deployment
|
|
96
|
+
configuration unambiguously.
|
|
97
|
+
|
|
91
98
|
An operator can run one Profile without Feishu delivery by piping input to `trigger`; the Final Outcome is printed to stdout:
|
|
92
99
|
|
|
93
100
|
```sh
|
|
94
101
|
printf '%s\n' 'Summarize the workspace status.' | \
|
|
95
|
-
beacon trigger --
|
|
102
|
+
beacon trigger --profile example --input -
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
An operator can also immediately run a configured Schedule through the real
|
|
106
|
+
Schedule input and Feishu chat Delivery path:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
beacon schedule trigger --profile example --schedule daily-report
|
|
96
110
|
```
|
|
97
111
|
|
|
112
|
+
This creates a distinct durable Run on every invocation and prints its
|
|
113
|
+
`run_id`. It uses the Schedule's configured `input` and `delivery.chat_id`, but
|
|
114
|
+
does not read, initialize, or advance the Schedule's cron cursor. Unknown
|
|
115
|
+
Profiles or Schedules and failed Runs or Deliveries exit non-zero; failure
|
|
116
|
+
messages include the `run_id` whenever a Run record was created.
|
|
117
|
+
|
|
118
|
+
## Feishu Final Outcome cards
|
|
119
|
+
|
|
120
|
+
An Agent explicitly calls one of two Final Outcome tools:
|
|
121
|
+
|
|
122
|
+
- `submit_final_outcome_text` sends a normal Feishu text message.
|
|
123
|
+
- `submit_final_outcome_card` sends an interactive card.
|
|
124
|
+
|
|
125
|
+
The card tool accepts a title, Feishu-compatible Markdown body, and up to five
|
|
126
|
+
HTTP(S) link buttons. The first button is styled as primary. The tools accept
|
|
127
|
+
these argument shapes:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{ "text": "The task completed successfully." }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"title": "AMR 生产发布影响报告",
|
|
136
|
+
"content": "- XXL | CMS 活动生命周期、实时 Test 与生产 Campaign\n- XL | Astra 272K+ 长上下文费率",
|
|
137
|
+
"buttons": [
|
|
138
|
+
{ "label": "查看 HTML 报告", "url": "https://example.com/report" },
|
|
139
|
+
{ "label": "查看 GitHub Compare", "url": "https://github.com/example/compare" }
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The selected form is stored durably as part of the Final Outcome. Both quoted
|
|
145
|
+
message replies and scheduled chat messages preserve that choice: text remains
|
|
146
|
+
text, while cards are sent with Feishu's `interactive` message type and include
|
|
147
|
+
the card generation time. Manual local triggers render card outcomes as
|
|
148
|
+
readable Markdown on stdout.
|
|
149
|
+
|
|
98
150
|
## State and failure behavior
|
|
99
151
|
|
|
100
152
|
Per-Profile state lives below `profiles/<profile-id>/state/`. Trigger claims, normalized inputs, Run state, Final Outcomes, Delivery state, and Schedule cursors use durable JSON snapshots. Records do not expire, and Beacon has no automatic cleanup task. Manually deleting state also deletes its deduplication memory.
|
|
@@ -143,6 +195,12 @@ launchctl print gui/$(id -u)/io.nettee.beacon
|
|
|
143
195
|
|
|
144
196
|
The example uses a restrictive umask and asks launchd to restart only after abnormal exit. `SIGTERM` initiates an orderly shutdown: timers and Gateways stop, accepted intake work drains, and the local Outcome server closes.
|
|
145
197
|
|
|
198
|
+
The repository also contains Chinese operator runbooks based on the verified
|
|
199
|
+
`macmini.liuyi` deployment:
|
|
200
|
+
|
|
201
|
+
- [Deploy or update Beacon on macmini](docs/macmini-deploy-and-update.md)
|
|
202
|
+
- [Add a Beacon Profile on macmini](docs/macmini-add-profile.md)
|
|
203
|
+
|
|
146
204
|
## Development checks
|
|
147
205
|
|
|
148
206
|
Development requires pnpm 10.33.2. Install dependencies before running checks
|
package/dist/cli-app.js
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
export const defaultConfigPath = join(homedir(), ".beacon", "config.yaml");
|
|
2
4
|
function usage() {
|
|
3
5
|
throw new Error(`Usage:
|
|
4
6
|
beacon serve --config <absolute-path>
|
|
5
|
-
beacon doctor --config <absolute-path>
|
|
6
|
-
beacon trigger --config <absolute-path> --profile <id> --input -
|
|
7
|
+
beacon doctor [--config <absolute-path>]
|
|
8
|
+
beacon trigger [--config <absolute-path>] --profile <id> --input -
|
|
9
|
+
beacon schedule trigger [--config <absolute-path>] --profile <id> --schedule <id>
|
|
7
10
|
beacon version`);
|
|
8
11
|
}
|
|
9
|
-
function
|
|
12
|
+
function requiredConfig(args) {
|
|
10
13
|
if (args.length !== 2 || args[0] !== "--config" || !args[1])
|
|
11
14
|
usage();
|
|
12
15
|
if (!isAbsolute(args[1]))
|
|
13
16
|
throw new Error("Config path must be absolute");
|
|
14
17
|
return args[1];
|
|
15
18
|
}
|
|
19
|
+
function options(args) {
|
|
20
|
+
if (args.length % 2 !== 0)
|
|
21
|
+
usage();
|
|
22
|
+
const values = new Map();
|
|
23
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
24
|
+
const key = args[index];
|
|
25
|
+
const value = args[index + 1];
|
|
26
|
+
if (!key?.startsWith("--") || !value || values.has(key))
|
|
27
|
+
usage();
|
|
28
|
+
values.set(key, value);
|
|
29
|
+
}
|
|
30
|
+
return values;
|
|
31
|
+
}
|
|
32
|
+
function optionalConfig(values) {
|
|
33
|
+
const config = values.get("--config") ?? defaultConfigPath;
|
|
34
|
+
if (!isAbsolute(config))
|
|
35
|
+
throw new Error("Config path must be absolute");
|
|
36
|
+
return config;
|
|
37
|
+
}
|
|
16
38
|
export function parseCli(args) {
|
|
17
39
|
const [command, ...rest] = args;
|
|
18
40
|
if (command === "version") {
|
|
@@ -20,30 +42,38 @@ export function parseCli(args) {
|
|
|
20
42
|
usage();
|
|
21
43
|
return { command: "version" };
|
|
22
44
|
}
|
|
23
|
-
if (command === "serve"
|
|
24
|
-
return { command, config:
|
|
45
|
+
if (command === "serve") {
|
|
46
|
+
return { command, config: requiredConfig(rest) };
|
|
25
47
|
}
|
|
26
|
-
if (command === "
|
|
27
|
-
|
|
48
|
+
if (command === "doctor") {
|
|
49
|
+
const values = options(rest);
|
|
50
|
+
if ([...values.keys()].some((key) => key !== "--config"))
|
|
28
51
|
usage();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (!key?.startsWith("--") || !value || values.has(key))
|
|
34
|
-
usage();
|
|
35
|
-
values.set(key, value);
|
|
36
|
-
}
|
|
37
|
-
const config = values.get("--config");
|
|
52
|
+
return { command, config: optionalConfig(values) };
|
|
53
|
+
}
|
|
54
|
+
if (command === "trigger") {
|
|
55
|
+
const values = options(rest);
|
|
38
56
|
const profile = values.get("--profile");
|
|
39
|
-
if (!
|
|
40
|
-
!profile ||
|
|
57
|
+
if (!profile ||
|
|
41
58
|
values.get("--input") !== "-" ||
|
|
42
|
-
values.
|
|
59
|
+
[...values.keys()].some((key) => !["--config", "--profile", "--input"].includes(key)))
|
|
60
|
+
usage();
|
|
61
|
+
return { command: "trigger", config: optionalConfig(values), profile };
|
|
62
|
+
}
|
|
63
|
+
if (command === "schedule" && rest[0] === "trigger") {
|
|
64
|
+
const values = options(rest.slice(1));
|
|
65
|
+
const profile = values.get("--profile");
|
|
66
|
+
const schedule = values.get("--schedule");
|
|
67
|
+
if (!profile ||
|
|
68
|
+
!schedule ||
|
|
69
|
+
[...values.keys()].some((key) => !["--config", "--profile", "--schedule"].includes(key)))
|
|
43
70
|
usage();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
return {
|
|
72
|
+
command: "schedule-trigger",
|
|
73
|
+
config: optionalConfig(values),
|
|
74
|
+
profile,
|
|
75
|
+
schedule,
|
|
76
|
+
};
|
|
47
77
|
}
|
|
48
78
|
if (command === "outcome" && rest.length === 1 && rest[0] === "submit") {
|
|
49
79
|
return { command: "outcome-submit" };
|
|
@@ -65,6 +95,9 @@ export async function runCli(command, dependencies) {
|
|
|
65
95
|
if (command.command === "trigger") {
|
|
66
96
|
return dependencies.trigger(command.config, command.profile, await readStdin());
|
|
67
97
|
}
|
|
98
|
+
if (command.command === "schedule-trigger") {
|
|
99
|
+
return dependencies.triggerSchedule(command.config, command.profile, command.schedule);
|
|
100
|
+
}
|
|
68
101
|
if (command.command === "outcome-submit")
|
|
69
102
|
return dependencies.submitOutcome();
|
|
70
103
|
console.log(dependencies.version);
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { parseCli, runCli } from "./cli-app.js";
|
|
|
3
3
|
import { runDoctor } from "./doctor.js";
|
|
4
4
|
import { runManualTrigger } from "./manual-trigger.js";
|
|
5
5
|
import { submitOutcomeFromCli } from "./outcome/submit.js";
|
|
6
|
+
import { runScheduleTrigger } from "./schedule-trigger.js";
|
|
6
7
|
import { runBeacon } from "./service.js";
|
|
7
8
|
import { packageVersion } from "./version.js";
|
|
8
9
|
async function main() {
|
|
@@ -10,6 +11,7 @@ async function main() {
|
|
|
10
11
|
serve: runBeacon,
|
|
11
12
|
doctor: runDoctor,
|
|
12
13
|
trigger: runManualTrigger,
|
|
14
|
+
triggerSchedule: runScheduleTrigger,
|
|
13
15
|
submitOutcome: submitOutcomeFromCli,
|
|
14
16
|
version: packageVersion,
|
|
15
17
|
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function formatTimestamp(date, timeZone) {
|
|
2
|
+
if (Number.isNaN(date.getTime())) {
|
|
3
|
+
throw new Error("Final Outcome card timestamp must be a valid Date");
|
|
4
|
+
}
|
|
5
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
6
|
+
...(timeZone ? { timeZone } : {}),
|
|
7
|
+
year: "numeric",
|
|
8
|
+
month: "2-digit",
|
|
9
|
+
day: "2-digit",
|
|
10
|
+
hour: "2-digit",
|
|
11
|
+
minute: "2-digit",
|
|
12
|
+
second: "2-digit",
|
|
13
|
+
hourCycle: "h23",
|
|
14
|
+
}).formatToParts(date);
|
|
15
|
+
const value = (type) => {
|
|
16
|
+
const part = parts.find((candidate) => candidate.type === type)?.value;
|
|
17
|
+
if (!part)
|
|
18
|
+
throw new Error(`Missing ${type} in formatted card timestamp`);
|
|
19
|
+
return part;
|
|
20
|
+
};
|
|
21
|
+
return `${value("year")}-${value("month")}-${value("day")} ${value("hour")}:${value("minute")}:${value("second")}`;
|
|
22
|
+
}
|
|
23
|
+
export function buildFeishuFinalOutcomeCard(outcome, options = {}) {
|
|
24
|
+
const elements = [{ tag: "markdown", content: outcome.content }];
|
|
25
|
+
if (outcome.buttons.length > 0) {
|
|
26
|
+
elements.push({ tag: "hr" }, {
|
|
27
|
+
tag: "action",
|
|
28
|
+
actions: outcome.buttons.map((button, index) => ({
|
|
29
|
+
tag: "button",
|
|
30
|
+
text: { tag: "plain_text", content: button.label },
|
|
31
|
+
url: button.url,
|
|
32
|
+
type: index === 0 ? "primary" : "default",
|
|
33
|
+
})),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
elements.push({
|
|
37
|
+
tag: "note",
|
|
38
|
+
elements: [
|
|
39
|
+
{
|
|
40
|
+
tag: "plain_text",
|
|
41
|
+
content: `卡片生成时间:${formatTimestamp(options.generatedAt ?? new Date(), options.timeZone)}`,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
config: { wide_screen_mode: true },
|
|
47
|
+
header: {
|
|
48
|
+
template: "blue",
|
|
49
|
+
title: { tag: "plain_text", content: outcome.title },
|
|
50
|
+
},
|
|
51
|
+
elements,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/dist/feishu/gateway.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import { buildFeishuFinalOutcomeCard } from "./final-outcome-card.js";
|
|
2
3
|
export function waitForShutdown(signals = process) {
|
|
3
4
|
return new Promise((resolve) => {
|
|
4
5
|
const shutdown = () => {
|
|
@@ -20,8 +21,17 @@ function assertSucceeded(operation, result) {
|
|
|
20
21
|
throw new Error(`${operation} failed: code=${result.code} msg=${result.msg ?? "unknown"}`);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
24
|
+
export function encodeFeishuFinalOutcome(outcome) {
|
|
25
|
+
if (outcome.kind === "text") {
|
|
26
|
+
return {
|
|
27
|
+
msgType: "text",
|
|
28
|
+
content: JSON.stringify({ text: outcome.text }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
msgType: "interactive",
|
|
33
|
+
content: JSON.stringify(buildFeishuFinalOutcomeCard(outcome)),
|
|
34
|
+
};
|
|
25
35
|
}
|
|
26
36
|
export class FeishuGateway {
|
|
27
37
|
credentials;
|
|
@@ -96,13 +106,14 @@ export class FeishuGateway {
|
|
|
96
106
|
parentMessageId: message.parent_id,
|
|
97
107
|
};
|
|
98
108
|
}
|
|
99
|
-
async deliver(target,
|
|
109
|
+
async deliver(target, outcome) {
|
|
110
|
+
const message = encodeFeishuFinalOutcome(outcome);
|
|
100
111
|
if (target.kind === "reply") {
|
|
101
112
|
const result = await this.client.im.v1.message.reply({
|
|
102
113
|
path: { message_id: target.messageId },
|
|
103
114
|
data: {
|
|
104
|
-
msg_type:
|
|
105
|
-
content:
|
|
115
|
+
msg_type: message.msgType,
|
|
116
|
+
content: message.content,
|
|
106
117
|
reply_in_thread: false,
|
|
107
118
|
},
|
|
108
119
|
});
|
|
@@ -114,8 +125,8 @@ export class FeishuGateway {
|
|
|
114
125
|
params: { receive_id_type: "chat_id" },
|
|
115
126
|
data: {
|
|
116
127
|
receive_id: target.chatId,
|
|
117
|
-
msg_type:
|
|
118
|
-
content:
|
|
128
|
+
msg_type: message.msgType,
|
|
129
|
+
content: message.content,
|
|
119
130
|
},
|
|
120
131
|
});
|
|
121
132
|
assertSucceeded("deliver chat message", result);
|
package/dist/manual-trigger.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { loadGlobalConfig } from "./config/global.js";
|
|
3
3
|
import { loadProfileRegistry } from "./config/registry.js";
|
|
4
|
+
import { renderFinalOutcomeAsText } from "./outcome/content.js";
|
|
4
5
|
import { startOutcomeServer } from "./outcome/server.js";
|
|
5
6
|
import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
|
|
6
7
|
import { RunQueue } from "./run/queue.js";
|
|
@@ -27,11 +28,11 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
|
|
|
27
28
|
ingress: { text: input },
|
|
28
29
|
});
|
|
29
30
|
const delivery = {
|
|
30
|
-
async deliver(target,
|
|
31
|
+
async deliver(target, outcome) {
|
|
31
32
|
if (target.kind !== "local_stdout") {
|
|
32
33
|
throw new Error("Manual Trigger requires local stdout Delivery");
|
|
33
34
|
}
|
|
34
|
-
await output(
|
|
35
|
+
await output(renderFinalOutcomeAsText(outcome));
|
|
35
36
|
return {};
|
|
36
37
|
},
|
|
37
38
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const nonBlank = z.string().refine((value) => value.trim().length > 0, {
|
|
3
|
+
message: "must not be blank",
|
|
4
|
+
});
|
|
5
|
+
const buttonSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
label: nonBlank,
|
|
8
|
+
url: z
|
|
9
|
+
.url()
|
|
10
|
+
.refine((value) => value.startsWith("https://") || value.startsWith("http://"), "must use http or https"),
|
|
11
|
+
})
|
|
12
|
+
.strict();
|
|
13
|
+
export const finalOutcomeContentSchema = z.discriminatedUnion("kind", [
|
|
14
|
+
z.object({ kind: z.literal("text"), text: nonBlank }).strict(),
|
|
15
|
+
z
|
|
16
|
+
.object({
|
|
17
|
+
kind: z.literal("card"),
|
|
18
|
+
title: nonBlank,
|
|
19
|
+
content: nonBlank,
|
|
20
|
+
buttons: z.array(buttonSchema).max(5).default([]),
|
|
21
|
+
})
|
|
22
|
+
.strict(),
|
|
23
|
+
]);
|
|
24
|
+
export function parseFinalOutcomeContent(value) {
|
|
25
|
+
return finalOutcomeContentSchema.parse(value);
|
|
26
|
+
}
|
|
27
|
+
export function renderFinalOutcomeAsText(outcome) {
|
|
28
|
+
if (outcome.kind === "text")
|
|
29
|
+
return outcome.text;
|
|
30
|
+
return [
|
|
31
|
+
`# ${outcome.title}`,
|
|
32
|
+
"",
|
|
33
|
+
outcome.content,
|
|
34
|
+
...outcome.buttons.map((button) => `\n[${button.label}](${button.url})`),
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
package/dist/outcome/doctor.js
CHANGED
|
@@ -18,7 +18,7 @@ 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.trim() !== "OUTCOME_CLI_OK") {
|
|
21
|
+
if (result.kind !== "text" || result.text.trim() !== "OUTCOME_CLI_OK") {
|
|
22
22
|
throw new Error(`Outcome doctor received unexpected submitted text: ${JSON.stringify(result)}`);
|
|
23
23
|
}
|
|
24
24
|
console.log("[beacon] explicit Final Outcome chain ready");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const finalOutcomeSystemInstructions = [
|
|
2
|
+
"When your work is complete, call exactly one of submit_final_outcome_text or submit_final_outcome_card with the exact user-facing response.",
|
|
3
|
+
"Use submit_final_outcome_text for a normal response, or submit_final_outcome_card when a titled Markdown summary and link buttons materially improve the result.",
|
|
4
|
+
"Beacon ignores ordinary assistant final text for Delivery.",
|
|
5
|
+
];
|
package/dist/outcome/server.js
CHANGED
|
@@ -3,6 +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 { parseFinalOutcomeContent } from "./content.js";
|
|
6
7
|
function respond(socket, response) {
|
|
7
8
|
socket.end(`${JSON.stringify(response)}\n`);
|
|
8
9
|
}
|
|
@@ -45,13 +46,22 @@ export async function startOutcomeServer(options = {}) {
|
|
|
45
46
|
if (typeof request !== "object" ||
|
|
46
47
|
request === null ||
|
|
47
48
|
!("runToken" in request) ||
|
|
48
|
-
!("
|
|
49
|
-
typeof request.runToken !== "string"
|
|
50
|
-
typeof request.text !== "string" ||
|
|
51
|
-
!request.text.trim()) {
|
|
49
|
+
!("outcome" in request) ||
|
|
50
|
+
typeof request.runToken !== "string") {
|
|
52
51
|
respond(socket, {
|
|
53
52
|
ok: false,
|
|
54
|
-
error: "runToken and
|
|
53
|
+
error: "runToken and outcome are required",
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let outcome;
|
|
58
|
+
try {
|
|
59
|
+
outcome = parseFinalOutcomeContent(request.outcome);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
respond(socket, {
|
|
63
|
+
ok: false,
|
|
64
|
+
error: `invalid Final Outcome: ${error instanceof Error ? error.message : String(error)}`,
|
|
55
65
|
});
|
|
56
66
|
return;
|
|
57
67
|
}
|
|
@@ -60,14 +70,14 @@ export async function startOutcomeServer(options = {}) {
|
|
|
60
70
|
respond(socket, { ok: false, error: "unknown Run Capability" });
|
|
61
71
|
return;
|
|
62
72
|
}
|
|
63
|
-
if (submission.
|
|
73
|
+
if (submission.outcome !== undefined) {
|
|
64
74
|
respond(socket, {
|
|
65
75
|
ok: false,
|
|
66
76
|
error: "Final Outcome already submitted",
|
|
67
77
|
});
|
|
68
78
|
return;
|
|
69
79
|
}
|
|
70
|
-
submission.
|
|
80
|
+
submission.outcome = outcome;
|
|
71
81
|
console.log("[beacon] Final Outcome submitted by Agent Runtime");
|
|
72
82
|
respond(socket, { ok: true });
|
|
73
83
|
});
|
|
@@ -88,10 +98,10 @@ export async function startOutcomeServer(options = {}) {
|
|
|
88
98
|
binding: { socketPath, runToken },
|
|
89
99
|
take() {
|
|
90
100
|
submissions.delete(runToken);
|
|
91
|
-
if (record.
|
|
101
|
+
if (record.outcome === undefined) {
|
|
92
102
|
throw new Error("Agent Runtime settled without submitting a Final Outcome");
|
|
93
103
|
}
|
|
94
|
-
return record.
|
|
104
|
+
return record.outcome;
|
|
95
105
|
},
|
|
96
106
|
cancel() {
|
|
97
107
|
submissions.delete(runToken);
|
package/dist/outcome/submit.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createConnection } from "node:net";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { parseFinalOutcomeContent } from "./content.js";
|
|
3
|
+
export async function submitOutcome(socketPath, runToken, outcome) {
|
|
4
|
+
const validatedOutcome = parseFinalOutcomeContent(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,
|
|
10
|
+
socket.write(`${JSON.stringify({ runToken, outcome: validatedOutcome })}\n`);
|
|
11
11
|
});
|
|
12
12
|
socket.on("data", (chunk) => {
|
|
13
13
|
responseBuffer += chunk;
|
|
@@ -39,9 +39,18 @@ export async function submitOutcomeFromCli() {
|
|
|
39
39
|
throw new Error("This command must run inside a Beacon-managed Agent Run");
|
|
40
40
|
}
|
|
41
41
|
process.stdin.setEncoding("utf8");
|
|
42
|
-
let
|
|
42
|
+
let input = "";
|
|
43
43
|
for await (const chunk of process.stdin)
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
input += chunk;
|
|
45
|
+
let outcome;
|
|
46
|
+
try {
|
|
47
|
+
outcome = parseFinalOutcomeContent(JSON.parse(input));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error("Final Outcome stdin must be valid outcome JSON", {
|
|
51
|
+
cause: error,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
await submitOutcome(socketPath, runToken, outcome);
|
|
46
55
|
console.log("Final Outcome accepted by Beacon");
|
|
47
56
|
}
|
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 { finalOutcomeSystemInstructions } from "../outcome/instructions.js";
|
|
3
4
|
import { PiRuntimeError } from "../runtime/pi-rpc.js";
|
|
4
5
|
function promptFor(input) {
|
|
5
6
|
if (input.kind === "manual") {
|
|
@@ -98,7 +99,7 @@ export class RunOrchestrator {
|
|
|
98
99
|
},
|
|
99
100
|
}));
|
|
100
101
|
try {
|
|
101
|
-
const result = await this.options.delivery.deliver(record.target, record.finalOutcome.
|
|
102
|
+
const result = await this.options.delivery.deliver(record.target, record.finalOutcome.content, record.delivery.deliveryId);
|
|
102
103
|
await this.options.store.update(triggerKey, (current) => ({
|
|
103
104
|
...current,
|
|
104
105
|
delivery: {
|
|
@@ -135,7 +136,10 @@ export class RunOrchestrator {
|
|
|
135
136
|
},
|
|
136
137
|
finalOutcome: {
|
|
137
138
|
origin: "beacon_failure",
|
|
138
|
-
|
|
139
|
+
content: {
|
|
140
|
+
kind: "text",
|
|
141
|
+
text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
|
|
142
|
+
},
|
|
139
143
|
submittedAt: this.timestamp(),
|
|
140
144
|
},
|
|
141
145
|
}));
|
|
@@ -180,8 +184,7 @@ export class RunOrchestrator {
|
|
|
180
184
|
systemPrompt: [
|
|
181
185
|
this.options.profile.prompt,
|
|
182
186
|
"",
|
|
183
|
-
|
|
184
|
-
"Beacon ignores ordinary assistant final text for Delivery.",
|
|
187
|
+
...finalOutcomeSystemInstructions,
|
|
185
188
|
].join("\n"),
|
|
186
189
|
outcome: { ...submission.binding, cliPath: this.options.beaconCliPath },
|
|
187
190
|
session: {
|
|
@@ -202,7 +205,7 @@ export class RunOrchestrator {
|
|
|
202
205
|
},
|
|
203
206
|
finalOutcome: {
|
|
204
207
|
origin: "agent",
|
|
205
|
-
|
|
208
|
+
content: outcome,
|
|
206
209
|
submittedAt: this.timestamp(),
|
|
207
210
|
},
|
|
208
211
|
}));
|
|
@@ -234,7 +237,10 @@ export class RunOrchestrator {
|
|
|
234
237
|
}),
|
|
235
238
|
finalOutcome: {
|
|
236
239
|
origin: "beacon_failure",
|
|
237
|
-
|
|
240
|
+
content: {
|
|
241
|
+
kind: "text",
|
|
242
|
+
text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
|
|
243
|
+
},
|
|
238
244
|
submittedAt: this.timestamp(),
|
|
239
245
|
},
|
|
240
246
|
}));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finalOutcomeSystemInstructions } from "../outcome/instructions.js";
|
|
1
2
|
import { runPiAgent, } from "../runtime/pi-rpc.js";
|
|
2
3
|
function messageForPrompt(message) {
|
|
3
4
|
return {
|
|
@@ -29,8 +30,7 @@ export function createProfileRunner(profile, outcomes, beaconCliPath, runAgent =
|
|
|
29
30
|
systemPrompt: [
|
|
30
31
|
profile.prompt,
|
|
31
32
|
"",
|
|
32
|
-
|
|
33
|
-
"Beacon ignores your final assistant response for Delivery; only the submitted text is delivered.",
|
|
33
|
+
...finalOutcomeSystemInstructions,
|
|
34
34
|
].join("\n"),
|
|
35
35
|
outcome: { ...submission.binding, cliPath: beaconCliPath },
|
|
36
36
|
});
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
|
|
2
|
+
import { parseFinalOutcomeContent } from "../outcome/content.js";
|
|
3
|
+
export function textOutcomeFromToolParams(params) {
|
|
4
|
+
return parseFinalOutcomeContent({ kind: "text", text: params.text });
|
|
5
|
+
}
|
|
6
|
+
export function cardOutcomeFromToolParams(params) {
|
|
7
|
+
return parseFinalOutcomeContent({
|
|
8
|
+
kind: "card",
|
|
9
|
+
title: params.title,
|
|
10
|
+
content: params.content,
|
|
11
|
+
buttons: params.buttons ?? [],
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
async function invokeBeaconCli(outcome, signal) {
|
|
3
15
|
const cliPath = process.env.BEACON_CLI_PATH;
|
|
4
16
|
if (!cliPath)
|
|
5
17
|
throw new Error("BEACON_CLI_PATH is missing from this Agent Run");
|
|
@@ -22,32 +34,74 @@ async function invokeBeaconCli(text, signal) {
|
|
|
22
34
|
}
|
|
23
35
|
reject(new Error(`beacon outcome submit failed (code=${String(code)} signal=${String(exitSignal)}): ${stderr.trim()}`));
|
|
24
36
|
});
|
|
25
|
-
child.stdin.end(
|
|
37
|
+
child.stdin.end(JSON.stringify(outcome));
|
|
26
38
|
});
|
|
27
39
|
}
|
|
28
|
-
|
|
40
|
+
function acceptedResult() {
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: "text", text: "Final Outcome accepted by Beacon." }],
|
|
43
|
+
details: { submitted: true },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export default function registerOutcomeTools(pi) {
|
|
29
47
|
pi.registerTool({
|
|
30
|
-
name: "
|
|
31
|
-
label: "Submit Final Outcome",
|
|
32
|
-
description: "Submit the
|
|
48
|
+
name: "submit_final_outcome_text",
|
|
49
|
+
label: "Submit Final Outcome Text",
|
|
50
|
+
description: "Submit the final response as a normal text message. Call either this tool or submit_final_outcome_card exactly once after completing the task.",
|
|
33
51
|
parameters: {
|
|
34
52
|
type: "object",
|
|
35
53
|
properties: {
|
|
36
54
|
text: {
|
|
37
55
|
type: "string",
|
|
38
56
|
minLength: 1,
|
|
39
|
-
description: "The complete user-facing
|
|
57
|
+
description: "The complete user-facing plain-text response.",
|
|
40
58
|
},
|
|
41
59
|
},
|
|
42
60
|
required: ["text"],
|
|
43
61
|
additionalProperties: false,
|
|
44
62
|
},
|
|
45
63
|
async execute(_toolCallId, params, signal) {
|
|
46
|
-
await invokeBeaconCli(params
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
await invokeBeaconCli(textOutcomeFromToolParams(params), signal);
|
|
65
|
+
return acceptedResult();
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
pi.registerTool({
|
|
69
|
+
name: "submit_final_outcome_card",
|
|
70
|
+
label: "Submit Final Outcome Card",
|
|
71
|
+
description: "Submit the final response as a structured Feishu card. Call either this tool or submit_final_outcome_text exactly once after completing the task.",
|
|
72
|
+
parameters: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
title: {
|
|
76
|
+
type: "string",
|
|
77
|
+
minLength: 1,
|
|
78
|
+
description: "The card header title.",
|
|
79
|
+
},
|
|
80
|
+
content: {
|
|
81
|
+
type: "string",
|
|
82
|
+
minLength: 1,
|
|
83
|
+
description: "The card body in Feishu-compatible Markdown.",
|
|
84
|
+
},
|
|
85
|
+
buttons: {
|
|
86
|
+
type: "array",
|
|
87
|
+
maxItems: 5,
|
|
88
|
+
items: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
label: { type: "string", minLength: 1 },
|
|
92
|
+
url: { type: "string", pattern: "^https?://" },
|
|
93
|
+
},
|
|
94
|
+
required: ["label", "url"],
|
|
95
|
+
additionalProperties: false,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
required: ["title", "content"],
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
},
|
|
102
|
+
async execute(_toolCallId, params, signal) {
|
|
103
|
+
await invokeBeaconCli(cardOutcomeFromToolParams(params), signal);
|
|
104
|
+
return acceptedResult();
|
|
51
105
|
},
|
|
52
106
|
});
|
|
53
107
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { loadGlobalConfig } from "./config/global.js";
|
|
3
|
+
import { loadProfileRegistry } from "./config/registry.js";
|
|
4
|
+
import { loadFeishuCredentials } from "./config/secrets.js";
|
|
5
|
+
import { createFeishuGateway } from "./feishu/gateway.js";
|
|
6
|
+
import { startOutcomeServer } from "./outcome/server.js";
|
|
7
|
+
import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
|
|
8
|
+
import { RunQueue } from "./run/queue.js";
|
|
9
|
+
import { TriggerStore } from "./state/trigger-store.js";
|
|
10
|
+
function findSchedule(profile, scheduleId) {
|
|
11
|
+
const schedule = profile.schedules.find((candidate) => candidate.id === scheduleId);
|
|
12
|
+
if (!schedule) {
|
|
13
|
+
throw new Error(`Unknown Schedule in Profile ${profile.id}: ${scheduleId}`);
|
|
14
|
+
}
|
|
15
|
+
return schedule;
|
|
16
|
+
}
|
|
17
|
+
export async function triggerScheduleOnce(options) {
|
|
18
|
+
const schedule = findSchedule(options.profile, options.scheduleId);
|
|
19
|
+
const scheduledFor = (options.now ?? (() => new Date()))().toISOString();
|
|
20
|
+
const invocationId = (options.id ?? randomUUID)();
|
|
21
|
+
const claim = await options.store.claim({
|
|
22
|
+
sourceKey: ["manual-schedule", schedule.id, invocationId],
|
|
23
|
+
target: { kind: "chat", chatId: schedule.delivery.chatId },
|
|
24
|
+
ingress: {
|
|
25
|
+
invocation: "manual",
|
|
26
|
+
scheduleId: schedule.id,
|
|
27
|
+
scheduledFor,
|
|
28
|
+
text: schedule.input,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
if (!claim.created) {
|
|
32
|
+
throw new Error(`Duplicate manual Schedule invocation: ${invocationId}`);
|
|
33
|
+
}
|
|
34
|
+
await options.process(claim.record.triggerKey, async () => ({
|
|
35
|
+
kind: "schedule",
|
|
36
|
+
scheduleId: schedule.id,
|
|
37
|
+
scheduledFor,
|
|
38
|
+
text: schedule.input,
|
|
39
|
+
}));
|
|
40
|
+
const record = (await options.store.list()).find((candidate) => candidate.triggerKey === claim.record.triggerKey);
|
|
41
|
+
if (!record?.run) {
|
|
42
|
+
throw new Error(`Schedule Trigger produced no Run record: trigger_id=${claim.record.triggerId}`);
|
|
43
|
+
}
|
|
44
|
+
if (record.run.state !== "succeeded" ||
|
|
45
|
+
record.delivery?.state !== "delivered") {
|
|
46
|
+
const failure = record.run.failure?.code ?? record.delivery?.failure?.code ?? "unknown";
|
|
47
|
+
throw new Error(`Schedule Trigger failed (run_id=${record.run.runId}): ${failure}`);
|
|
48
|
+
}
|
|
49
|
+
return record;
|
|
50
|
+
}
|
|
51
|
+
export async function runScheduleTrigger(configPath, profileId, scheduleId) {
|
|
52
|
+
const global = await loadGlobalConfig(configPath);
|
|
53
|
+
const profiles = await loadProfileRegistry(global.profilesDirectory);
|
|
54
|
+
const profile = profiles.find((candidate) => candidate.id === profileId);
|
|
55
|
+
if (!profile)
|
|
56
|
+
throw new Error(`Unknown Profile: ${profileId}`);
|
|
57
|
+
findSchedule(profile, scheduleId);
|
|
58
|
+
const credentials = await loadFeishuCredentials(profile.id, global.secretsPath);
|
|
59
|
+
const outcomes = await startOutcomeServer();
|
|
60
|
+
try {
|
|
61
|
+
const store = new TriggerStore(profile.directory, profile.id);
|
|
62
|
+
const orchestrator = createPiRunOrchestrator({
|
|
63
|
+
config: global,
|
|
64
|
+
profile,
|
|
65
|
+
store,
|
|
66
|
+
queue: new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued),
|
|
67
|
+
outcomes,
|
|
68
|
+
delivery: createFeishuGateway(credentials),
|
|
69
|
+
});
|
|
70
|
+
const record = await triggerScheduleOnce({
|
|
71
|
+
profile,
|
|
72
|
+
scheduleId,
|
|
73
|
+
store,
|
|
74
|
+
process: (triggerKey, normalize) => orchestrator.process(triggerKey, normalize),
|
|
75
|
+
});
|
|
76
|
+
console.log(`[beacon] Schedule Trigger succeeded run_id=${record.run.runId}`);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await outcomes.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -3,6 +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
7
|
const timestamp = z.string().datetime({ offset: true });
|
|
7
8
|
const failureSchema = z
|
|
8
9
|
.object({ code: z.enum(failureCodes), summary: z.string().min(1).max(4096) })
|
|
@@ -64,13 +65,27 @@ const runSchema = z
|
|
|
64
65
|
failure: failureSchema.optional(),
|
|
65
66
|
})
|
|
66
67
|
.strict();
|
|
67
|
-
const outcomeSchema = z
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const outcomeSchema = z.union([
|
|
69
|
+
z
|
|
70
|
+
.object({
|
|
71
|
+
origin: z.enum(["agent", "beacon_failure"]),
|
|
72
|
+
content: finalOutcomeContentSchema,
|
|
73
|
+
submittedAt: timestamp,
|
|
74
|
+
})
|
|
75
|
+
.strict(),
|
|
76
|
+
z
|
|
77
|
+
.object({
|
|
78
|
+
origin: z.enum(["agent", "beacon_failure"]),
|
|
79
|
+
text: z.string().min(1),
|
|
80
|
+
submittedAt: timestamp,
|
|
81
|
+
})
|
|
82
|
+
.strict()
|
|
83
|
+
.transform(({ origin, text, submittedAt }) => ({
|
|
84
|
+
origin,
|
|
85
|
+
content: { kind: "text", text },
|
|
86
|
+
submittedAt,
|
|
87
|
+
})),
|
|
88
|
+
]);
|
|
74
89
|
const deliverySchema = z
|
|
75
90
|
.object({
|
|
76
91
|
deliveryId: z.string().min(1),
|