@nettee/beacon 0.1.4 → 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 CHANGED
@@ -115,6 +115,38 @@ 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
+ ## 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
+
118
150
  ## State and failure behavior
119
151
 
120
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.
@@ -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
+ }
@@ -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 textContent(text) {
24
- return JSON.stringify({ text });
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, text) {
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: "text",
105
- content: textContent(text),
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: "text",
118
- content: textContent(text),
128
+ msg_type: message.msgType,
129
+ content: message.content,
119
130
  },
120
131
  });
121
132
  assertSucceeded("deliver chat message", result);
@@ -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, text) {
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(text);
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
+ }
@@ -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
+ ];
@@ -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
- !("text" in request) ||
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 non-empty text are required",
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.text !== undefined) {
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.text = request.text;
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.text === undefined) {
101
+ if (record.outcome === undefined) {
92
102
  throw new Error("Agent Runtime settled without submitting a Final Outcome");
93
103
  }
94
- return record.text;
104
+ return record.outcome;
95
105
  },
96
106
  cancel() {
97
107
  submissions.delete(runToken);
@@ -1,13 +1,13 @@
1
1
  import { createConnection } from "node:net";
2
- export async function submitOutcome(socketPath, runToken, text) {
3
- if (!text.trim())
4
- throw new Error("Final Outcome must not be empty");
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, text })}\n`);
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 text = "";
42
+ let input = "";
43
43
  for await (const chunk of process.stdin)
44
- text += chunk;
45
- await submitOutcome(socketPath, runToken, text);
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
  }
@@ -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.text, record.delivery.deliveryId);
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
- text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
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
- "When your work is complete, call submit_final_outcome exactly once with the exact user-facing response.",
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
- text: outcome,
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
- text: `处理失败(run_id=${runId}),请查看 Beacon 本地记录。`,
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
- "When your work is complete, you must call the submit_final_outcome tool exactly once with the exact user-facing response.",
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
- async function invokeBeaconCli(text, signal) {
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(text);
37
+ child.stdin.end(JSON.stringify(outcome));
26
38
  });
27
39
  }
28
- export default function registerOutcomeTool(pi) {
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: "submit_final_outcome",
31
- label: "Submit Final Outcome",
32
- description: "Submit the exact final response that Beacon must deliver to the user. Call this once after completing the task.",
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 final response.",
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.text, signal);
47
- return {
48
- content: [{ type: "text", text: "Final Outcome accepted by Beacon." }],
49
- details: { submitted: true },
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
  }
@@ -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
- .object({
69
- origin: z.enum(["agent", "beacon_failure"]),
70
- text: z.string().min(1),
71
- submittedAt: timestamp,
72
- })
73
- .strict();
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {