@nettee/beacon 0.1.4 → 0.1.6
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 +48 -4
- package/dist/feishu/final-outcome-card.js +53 -0
- package/dist/feishu/gateway.js +33 -7
- package/dist/feishu/intake.js +15 -2
- package/dist/manual-trigger.js +5 -3
- package/dist/outcome/content.js +39 -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 +15 -7
- package/dist/run/profile-runner.js +2 -2
- package/dist/runtime/pi-outcome-extension.js +90 -12
- package/dist/state/trigger-store.js +22 -7
- package/examples/profiles/example/prompt.md +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,46 @@ does not read, initialize, or advance the Schedule's cron cursor. Unknown
|
|
|
115
115
|
Profiles or Schedules and failed Runs or Deliveries exit non-zero; failure
|
|
116
116
|
messages include the `run_id` whenever a Run record was created.
|
|
117
117
|
|
|
118
|
+
## Final Outcomes
|
|
119
|
+
|
|
120
|
+
An Agent explicitly calls exactly one of three 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
|
+
- `submit_final_outcome_no_reply` completes the Run without creating a
|
|
125
|
+
Delivery. Its required `reason` is stored for internal audit and is never
|
|
126
|
+
sent to the user.
|
|
127
|
+
|
|
128
|
+
The card tool accepts a title, Feishu-compatible Markdown body, and up to five
|
|
129
|
+
HTTP(S) link buttons. The first button is styled as primary. The tools accept
|
|
130
|
+
these argument shapes:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{ "text": "The task completed successfully." }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"title": "AMR 生产发布影响报告",
|
|
139
|
+
"content": "- XXL | CMS 活动生命周期、实时 Test 与生产 Campaign\n- XL | Astra 272K+ 长上下文费率",
|
|
140
|
+
"buttons": [
|
|
141
|
+
{ "label": "查看 HTML 报告", "url": "https://example.com/report" },
|
|
142
|
+
{ "label": "查看 GitHub Compare", "url": "https://github.com/example/compare" }
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{ "reason": "The group announcement is outside this Profile's role." }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The selected form is stored durably as part of the Final Outcome. Both quoted
|
|
152
|
+
message replies and scheduled chat messages preserve that choice: text remains
|
|
153
|
+
text, while cards are sent with Feishu's `interactive` message type and include
|
|
154
|
+
the card generation time. A no-reply outcome records a successful Run without
|
|
155
|
+
a Delivery record. Manual local triggers render card outcomes as readable
|
|
156
|
+
Markdown on stdout and print no user-facing content for no-reply outcomes.
|
|
157
|
+
|
|
118
158
|
## State and failure behavior
|
|
119
159
|
|
|
120
160
|
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.
|
|
@@ -168,6 +208,7 @@ The repository also contains Chinese operator runbooks based on the verified
|
|
|
168
208
|
|
|
169
209
|
- [Deploy or update Beacon on macmini](docs/macmini-deploy-and-update.md)
|
|
170
210
|
- [Add a Beacon Profile on macmini](docs/macmini-add-profile.md)
|
|
211
|
+
- [Write a role-bounded Profile Prompt](docs/profile-prompt-writing.md)
|
|
171
212
|
|
|
172
213
|
## Development checks
|
|
173
214
|
|
|
@@ -219,10 +260,13 @@ version change takes precedence over the automatic patch bump, and CI rejects
|
|
|
219
260
|
a changed version that is not greater than the base branch version.
|
|
220
261
|
|
|
221
262
|
The bump job accepts only branches in this repository, not pull requests from
|
|
222
|
-
forks.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
263
|
+
forks. Configure a repository secret named `AUTO_BUMP_TOKEN` with a fine-grained
|
|
264
|
+
GitHub token whose repository access is limited to `nettee/beacon` and whose
|
|
265
|
+
Contents permission is read/write. The checkout persists that credential for
|
|
266
|
+
the version commit push, so GitHub attributes the PR update to the token owner
|
|
267
|
+
and starts follow-up PR checks without an approval prompt. When the secret is
|
|
268
|
+
absent, CI falls back to the job-scoped `github.token`; the bump still succeeds,
|
|
269
|
+
but GitHub requires a maintainer to approve the resulting PR workflow run.
|
|
226
270
|
|
|
227
271
|
Submit the release candidate through the normal pull request checks. After the
|
|
228
272
|
initial package bootstrap, each push to `main` runs
|
|
@@ -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,20 @@ 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 === "no_reply") {
|
|
26
|
+
throw new Error("No-reply Outcomes must not be encoded for Delivery");
|
|
27
|
+
}
|
|
28
|
+
if (outcome.kind === "text") {
|
|
29
|
+
return {
|
|
30
|
+
msgType: "text",
|
|
31
|
+
content: JSON.stringify({ text: outcome.text }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
msgType: "interactive",
|
|
36
|
+
content: JSON.stringify(buildFeishuFinalOutcomeCard(outcome)),
|
|
37
|
+
};
|
|
25
38
|
}
|
|
26
39
|
export class FeishuGateway {
|
|
27
40
|
credentials;
|
|
@@ -74,6 +87,18 @@ export class FeishuGateway {
|
|
|
74
87
|
data: { reaction_type: { emoji_type: "OnIt" } },
|
|
75
88
|
});
|
|
76
89
|
assertSucceeded("add OnIt reaction", result);
|
|
90
|
+
const reactionId = result.data?.reaction_id;
|
|
91
|
+
if (!reactionId) {
|
|
92
|
+
throw new Error("add OnIt reaction returned no reaction_id");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
clear: async () => {
|
|
96
|
+
const deleteResult = await this.client.im.v1.messageReaction.delete({
|
|
97
|
+
path: { message_id: messageId, reaction_id: reactionId },
|
|
98
|
+
});
|
|
99
|
+
assertSucceeded("remove OnIt reaction", deleteResult);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
77
102
|
}
|
|
78
103
|
async fetchMessage(messageId) {
|
|
79
104
|
const result = await this.client.im.v1.message.get({
|
|
@@ -96,13 +121,14 @@ export class FeishuGateway {
|
|
|
96
121
|
parentMessageId: message.parent_id,
|
|
97
122
|
};
|
|
98
123
|
}
|
|
99
|
-
async deliver(target,
|
|
124
|
+
async deliver(target, outcome) {
|
|
125
|
+
const message = encodeFeishuFinalOutcome(outcome);
|
|
100
126
|
if (target.kind === "reply") {
|
|
101
127
|
const result = await this.client.im.v1.message.reply({
|
|
102
128
|
path: { message_id: target.messageId },
|
|
103
129
|
data: {
|
|
104
|
-
msg_type:
|
|
105
|
-
content:
|
|
130
|
+
msg_type: message.msgType,
|
|
131
|
+
content: message.content,
|
|
106
132
|
reply_in_thread: false,
|
|
107
133
|
},
|
|
108
134
|
});
|
|
@@ -114,8 +140,8 @@ export class FeishuGateway {
|
|
|
114
140
|
params: { receive_id_type: "chat_id" },
|
|
115
141
|
data: {
|
|
116
142
|
receive_id: target.chatId,
|
|
117
|
-
msg_type:
|
|
118
|
-
content:
|
|
143
|
+
msg_type: message.msgType,
|
|
144
|
+
content: message.content,
|
|
119
145
|
},
|
|
120
146
|
});
|
|
121
147
|
assertSucceeded("deliver chat message", result);
|
package/dist/feishu/intake.js
CHANGED
|
@@ -31,12 +31,14 @@ export class FeishuIntake {
|
|
|
31
31
|
});
|
|
32
32
|
if (!claim.created)
|
|
33
33
|
return "duplicate";
|
|
34
|
-
|
|
34
|
+
const acknowledgement = this.options
|
|
35
35
|
.acknowledge(event.message.message_id)
|
|
36
36
|
.catch((error) => {
|
|
37
37
|
console.error(`[beacon] non-critical acknowledgement failure message_id=${event.message.message_id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
return undefined;
|
|
38
39
|
});
|
|
39
|
-
const task = this.options
|
|
40
|
+
const task = this.options
|
|
41
|
+
.process(claim.record.triggerKey, async () => {
|
|
40
42
|
const normalized = await buildFeishuTriggerInput({
|
|
41
43
|
messageId: event.message.message_id,
|
|
42
44
|
chatId: event.message.chat_id,
|
|
@@ -54,6 +56,17 @@ export class FeishuIntake {
|
|
|
54
56
|
quotedMessages: normalized.quotedMessages,
|
|
55
57
|
currentMessage: normalized.message,
|
|
56
58
|
};
|
|
59
|
+
})
|
|
60
|
+
.finally(async () => {
|
|
61
|
+
const activeAcknowledgement = await acknowledgement;
|
|
62
|
+
if (!activeAcknowledgement)
|
|
63
|
+
return;
|
|
64
|
+
try {
|
|
65
|
+
await activeAcknowledgement.clear();
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error(`[beacon] non-critical acknowledgement cleanup failure message_id=${event.message.message_id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
69
|
+
}
|
|
57
70
|
});
|
|
58
71
|
this.active.add(task);
|
|
59
72
|
void task
|
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
|
};
|
|
@@ -49,7 +50,8 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
|
|
|
49
50
|
}));
|
|
50
51
|
const record = (await store.list()).find((candidate) => candidate.triggerKey === claim.record.triggerKey);
|
|
51
52
|
if (record?.run?.state !== "succeeded" ||
|
|
52
|
-
record.
|
|
53
|
+
(record.finalOutcome?.content.kind !== "no_reply" &&
|
|
54
|
+
record.delivery?.state !== "delivered")) {
|
|
53
55
|
throw new Error(`Manual Trigger failed: ${record?.run?.failure?.code ?? record?.delivery?.failure?.code ?? "unknown"}`);
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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.object({ kind: z.literal("no_reply"), reason: nonBlank }).strict(),
|
|
16
|
+
z
|
|
17
|
+
.object({
|
|
18
|
+
kind: z.literal("card"),
|
|
19
|
+
title: nonBlank,
|
|
20
|
+
content: nonBlank,
|
|
21
|
+
buttons: z.array(buttonSchema).max(5).default([]),
|
|
22
|
+
})
|
|
23
|
+
.strict(),
|
|
24
|
+
]);
|
|
25
|
+
export function parseFinalOutcomeContent(value) {
|
|
26
|
+
return finalOutcomeContentSchema.parse(value);
|
|
27
|
+
}
|
|
28
|
+
export function renderFinalOutcomeAsText(outcome) {
|
|
29
|
+
if (outcome.kind === "text")
|
|
30
|
+
return outcome.text;
|
|
31
|
+
if (outcome.kind === "no_reply")
|
|
32
|
+
return "";
|
|
33
|
+
return [
|
|
34
|
+
`# ${outcome.title}`,
|
|
35
|
+
"",
|
|
36
|
+
outcome.content,
|
|
37
|
+
...outcome.buttons.map((button) => `\n[${button.label}](${button.url})`),
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
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, submit_final_outcome_card, or submit_final_outcome_no_reply.",
|
|
3
|
+
"Use submit_final_outcome_text for a normal response, submit_final_outcome_card when a titled Markdown summary and link buttons materially improve the result, or submit_final_outcome_no_reply when the triggering message is outside the Profile's role and should receive no response.",
|
|
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,11 +205,12 @@ 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
|
}));
|
|
209
|
-
|
|
212
|
+
if (outcome.kind !== "no_reply")
|
|
213
|
+
await this.deliver(triggerKey);
|
|
210
214
|
}
|
|
211
215
|
catch (error) {
|
|
212
216
|
submission.cancel();
|
|
@@ -234,7 +238,10 @@ export class RunOrchestrator {
|
|
|
234
238
|
}),
|
|
235
239
|
finalOutcome: {
|
|
236
240
|
origin: "beacon_failure",
|
|
237
|
-
|
|
241
|
+
content: {
|
|
242
|
+
kind: "text",
|
|
243
|
+
text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
|
|
244
|
+
},
|
|
238
245
|
submittedAt: this.timestamp(),
|
|
239
246
|
},
|
|
240
247
|
}));
|
|
@@ -291,6 +298,7 @@ export class RunOrchestrator {
|
|
|
291
298
|
continue;
|
|
292
299
|
}
|
|
293
300
|
if (record.finalOutcome &&
|
|
301
|
+
record.finalOutcome.content.kind !== "no_reply" &&
|
|
294
302
|
!record.delivery &&
|
|
295
303
|
(record.run.state === "succeeded" || record.run.state === "failed")) {
|
|
296
304
|
await this.deliver(record.triggerKey);
|
|
@@ -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,20 @@
|
|
|
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
|
+
export function noReplyOutcomeFromToolParams(params) {
|
|
15
|
+
return parseFinalOutcomeContent({ kind: "no_reply", reason: params.reason });
|
|
16
|
+
}
|
|
17
|
+
async function invokeBeaconCli(outcome, signal) {
|
|
3
18
|
const cliPath = process.env.BEACON_CLI_PATH;
|
|
4
19
|
if (!cliPath)
|
|
5
20
|
throw new Error("BEACON_CLI_PATH is missing from this Agent Run");
|
|
@@ -22,32 +37,95 @@ async function invokeBeaconCli(text, signal) {
|
|
|
22
37
|
}
|
|
23
38
|
reject(new Error(`beacon outcome submit failed (code=${String(code)} signal=${String(exitSignal)}): ${stderr.trim()}`));
|
|
24
39
|
});
|
|
25
|
-
child.stdin.end(
|
|
40
|
+
child.stdin.end(JSON.stringify(outcome));
|
|
26
41
|
});
|
|
27
42
|
}
|
|
28
|
-
|
|
43
|
+
function acceptedResult() {
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: "Final Outcome accepted by Beacon." }],
|
|
46
|
+
details: { submitted: true },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export default function registerOutcomeTools(pi) {
|
|
29
50
|
pi.registerTool({
|
|
30
|
-
name: "
|
|
31
|
-
label: "Submit Final Outcome",
|
|
32
|
-
description: "Submit the
|
|
51
|
+
name: "submit_final_outcome_text",
|
|
52
|
+
label: "Submit Final Outcome Text",
|
|
53
|
+
description: "Submit the final response as a normal text message. Call exactly one Final Outcome tool after completing the task.",
|
|
33
54
|
parameters: {
|
|
34
55
|
type: "object",
|
|
35
56
|
properties: {
|
|
36
57
|
text: {
|
|
37
58
|
type: "string",
|
|
38
59
|
minLength: 1,
|
|
39
|
-
description: "The complete user-facing
|
|
60
|
+
description: "The complete user-facing plain-text response.",
|
|
40
61
|
},
|
|
41
62
|
},
|
|
42
63
|
required: ["text"],
|
|
43
64
|
additionalProperties: false,
|
|
44
65
|
},
|
|
45
66
|
async execute(_toolCallId, params, signal) {
|
|
46
|
-
await invokeBeaconCli(params
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
await invokeBeaconCli(textOutcomeFromToolParams(params), signal);
|
|
68
|
+
return acceptedResult();
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
pi.registerTool({
|
|
72
|
+
name: "submit_final_outcome_card",
|
|
73
|
+
label: "Submit Final Outcome Card",
|
|
74
|
+
description: "Submit the final response as a structured Feishu card. Call exactly one Final Outcome tool after completing the task.",
|
|
75
|
+
parameters: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
title: {
|
|
79
|
+
type: "string",
|
|
80
|
+
minLength: 1,
|
|
81
|
+
description: "The card header title.",
|
|
82
|
+
},
|
|
83
|
+
content: {
|
|
84
|
+
type: "string",
|
|
85
|
+
minLength: 1,
|
|
86
|
+
description: "The card body in Feishu-compatible Markdown.",
|
|
87
|
+
},
|
|
88
|
+
buttons: {
|
|
89
|
+
type: "array",
|
|
90
|
+
maxItems: 5,
|
|
91
|
+
items: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
label: { type: "string", minLength: 1 },
|
|
95
|
+
url: { type: "string", pattern: "^https?://" },
|
|
96
|
+
},
|
|
97
|
+
required: ["label", "url"],
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
required: ["title", "content"],
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
},
|
|
105
|
+
async execute(_toolCallId, params, signal) {
|
|
106
|
+
await invokeBeaconCli(cardOutcomeFromToolParams(params), signal);
|
|
107
|
+
return acceptedResult();
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
pi.registerTool({
|
|
111
|
+
name: "submit_final_outcome_no_reply",
|
|
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.",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ["reason"],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
async execute(_toolCallId, params, signal) {
|
|
127
|
+
await invokeBeaconCli(noReplyOutcomeFromToolParams(params), signal);
|
|
128
|
+
return acceptedResult();
|
|
51
129
|
},
|
|
52
130
|
});
|
|
53
131
|
}
|
|
@@ -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),
|
|
@@ -1 +1,7 @@
|
|
|
1
|
-
You are
|
|
1
|
+
You are a workspace-status assistant. Your only role is to answer requests about the configured workspace's files, version-control state, and verification results.
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
If the input is outside this role, immediately call `submit_final_outcome_no_reply` with a concise internal reason. Do not inspect the workspace or submit a text or card response.
|
|
6
|
+
|
|
7
|
+
For relevant input, work only within the configured workspace and return a concise, user-facing result through the appropriate Final Outcome tool. Required operations must fail visibly; do not fabricate fallback data or report success after incomplete work.
|