dsh-foreman-node 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +64 -1
- package/package.json +1 -1
- package/src/session.ts +57 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## 0.2.1 — 2026-11-08
|
|
2
|
+
|
|
3
|
+
### 修复:任务跑完了,却没有回报给 hub
|
|
4
|
+
|
|
5
|
+
真机上一次派单:设备做了 30 步、37 次工具调用、写了 2808 字的完整答案,**一次都没调用 `foreman_report`**。
|
|
6
|
+
hub 因此一直停在 `running`,面板上那张工单永远不结束,人也收不到结果。
|
|
7
|
+
|
|
8
|
+
两个原因,都修了:
|
|
9
|
+
|
|
10
|
+
1. **任务文本里没有说要回报。** 设备收到的只有标题、请求原文和附件说明,模型自然当成普通任务,
|
|
11
|
+
做完就结束。现在每张派单的末尾都有一段说明:`foreman_report` 是这个任务到达人的唯一途径,
|
|
12
|
+
答在对话里不算完成。
|
|
13
|
+
2. **只靠模型自觉不可靠。** 加了一层兜底:一轮结束而还没有回报时,插件自动补一条 `ready`,
|
|
14
|
+
摘要取会话里最后一段模型输出。工具被正确调用过就不会重复补。
|
|
15
|
+
|
|
16
|
+
兜底是真的兜底——任何一轮正常结束都会触发它,所以即使模型完全无视那段说明,任务也不会挂死。
|
|
17
|
+
|
|
18
|
+
### 加固
|
|
19
|
+
|
|
20
|
+
- 会话事件监听注册失败时(宿主不提供 `ctx.effect`),仍然会挂上回报工具:
|
|
21
|
+
没有回报通道比“看不见轮次结束”严重得多。
|
|
22
|
+
|
|
1
23
|
## 0.2.0 — 2026-11-07
|
|
2
24
|
|
|
3
25
|
### 新增:配置页,设备绑定不用再碰命令行
|
package/lib/index.d.ts
CHANGED
|
@@ -344,6 +344,8 @@ declare class JobSessions {
|
|
|
344
344
|
private readonly jobs;
|
|
345
345
|
/** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
|
|
346
346
|
private readonly reported;
|
|
347
|
+
/** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
|
|
348
|
+
private readonly reports;
|
|
347
349
|
constructor(ctx: Context);
|
|
348
350
|
/**
|
|
349
351
|
* Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
|
|
@@ -372,6 +374,17 @@ declare class JobSessions {
|
|
|
372
374
|
forget(assignmentId: string): void;
|
|
373
375
|
/** Attach the `foreman_report` tool to a job Session, disposed with it. */
|
|
374
376
|
reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void;
|
|
377
|
+
/**
|
|
378
|
+
* Report for a job that ended without calling the tool.
|
|
379
|
+
*
|
|
380
|
+
* Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
|
|
381
|
+
* answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
|
|
382
|
+
* failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
|
|
383
|
+
* as the summary. A person still gets the result; the ledger still learns the job ended.
|
|
384
|
+
*/
|
|
385
|
+
private watchTurnEnd;
|
|
386
|
+
/** The last text the assistant said in a Session, read from its stored history. */
|
|
387
|
+
private lastText;
|
|
375
388
|
}
|
|
376
389
|
/** Version reported in the hello, so the hub can tell which device build is talking. */
|
|
377
390
|
declare const VERSION = "0.2.0";
|
package/lib/index.js
CHANGED
|
@@ -877,6 +877,8 @@ var JobSessions = class {
|
|
|
877
877
|
jobs = /* @__PURE__ */ new Map();
|
|
878
878
|
/** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
|
|
879
879
|
reported = /* @__PURE__ */ new Set();
|
|
880
|
+
/** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
|
|
881
|
+
reports = /* @__PURE__ */ new Set();
|
|
880
882
|
constructor(ctx) {
|
|
881
883
|
this.ctx = ctx;
|
|
882
884
|
}
|
|
@@ -982,7 +984,12 @@ var JobSessions = class {
|
|
|
982
984
|
/** Attach the `foreman_report` tool to a job Session, disposed with it. */
|
|
983
985
|
reportTools(agent, assignmentId, onReport) {
|
|
984
986
|
const registrar = agent.ctx.tools;
|
|
985
|
-
const
|
|
987
|
+
const reported = this.reports;
|
|
988
|
+
const report = (id, deviceReport) => {
|
|
989
|
+
reported.add(id);
|
|
990
|
+
onReport(id, deviceReport);
|
|
991
|
+
};
|
|
992
|
+
const tool = foremanReportTool({ report }, assignmentId);
|
|
986
993
|
registrar.register({
|
|
987
994
|
name: tool.name,
|
|
988
995
|
description: tool.description,
|
|
@@ -999,6 +1006,62 @@ var JobSessions = class {
|
|
|
999
1006
|
},
|
|
1000
1007
|
execute: (args, execution) => tool.execute(args, execution)
|
|
1001
1008
|
});
|
|
1009
|
+
this.watchTurnEnd(agent, assignmentId, report);
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Report for a job that ended without calling the tool.
|
|
1013
|
+
*
|
|
1014
|
+
* Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
|
|
1015
|
+
* answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
|
|
1016
|
+
* failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
|
|
1017
|
+
* as the summary. A person still gets the result; the ledger still learns the job ended.
|
|
1018
|
+
*/
|
|
1019
|
+
watchTurnEnd(agent, assignmentId, report) {
|
|
1020
|
+
if (typeof agent.ctx.effect !== "function") return;
|
|
1021
|
+
agent.ctx.effect(() => {
|
|
1022
|
+
agent.ctx.on("session/event", (session, event) => {
|
|
1023
|
+
if (session.id !== agent.session.id || event.type !== "turn/end") return;
|
|
1024
|
+
if (this.reports.has(assignmentId)) return;
|
|
1025
|
+
this.lastText(agent).then((text) => {
|
|
1026
|
+
if (this.reports.has(assignmentId)) return;
|
|
1027
|
+
report(assignmentId, {
|
|
1028
|
+
state: "ready",
|
|
1029
|
+
summary: text === "" ? "the job finished without a report" : text.slice(0, 600)
|
|
1030
|
+
});
|
|
1031
|
+
}, () => {
|
|
1032
|
+
if (this.reports.has(assignmentId)) return;
|
|
1033
|
+
report(assignmentId, {
|
|
1034
|
+
state: "ready",
|
|
1035
|
+
summary: "the job finished without a report"
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
return () => {};
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
/** The last text the assistant said in a Session, read from its stored history. */
|
|
1043
|
+
async lastText(agent) {
|
|
1044
|
+
try {
|
|
1045
|
+
var _usingCtx3 = _usingCtx();
|
|
1046
|
+
await this.ctx.sessionPersistence.flush();
|
|
1047
|
+
const handle = _usingCtx3.a(await this.ctx.sessionPersistence.open(agent.id, "read"));
|
|
1048
|
+
let found = "";
|
|
1049
|
+
for (let offset = 0;;) {
|
|
1050
|
+
const { events } = await handle.read(offset, 512);
|
|
1051
|
+
if (events.length === 0) break;
|
|
1052
|
+
for (const event of events) {
|
|
1053
|
+
if (event.type !== "assistant/message") continue;
|
|
1054
|
+
const text = (event.data?.message?.content ?? []).filter((part) => part?.type === "text").map((part) => part.text ?? "").join(" ").trim();
|
|
1055
|
+
if (text !== "") found = text;
|
|
1056
|
+
}
|
|
1057
|
+
offset += events.length;
|
|
1058
|
+
}
|
|
1059
|
+
return found;
|
|
1060
|
+
} catch (_) {
|
|
1061
|
+
_usingCtx3.e = _;
|
|
1062
|
+
} finally {
|
|
1063
|
+
await _usingCtx3.d();
|
|
1064
|
+
}
|
|
1002
1065
|
}
|
|
1003
1066
|
};
|
|
1004
1067
|
// src/index.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-foreman-node",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Device side of dsh-foreman: connects out to a foreman hub, takes a job into a local DSH session, reports back. No ledger, no policy, no database.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/src/session.ts
CHANGED
|
@@ -60,6 +60,8 @@ export class JobSessions {
|
|
|
60
60
|
private readonly jobs = new Map<string, string>()
|
|
61
61
|
/** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
|
|
62
62
|
private readonly reported = new Set<string>()
|
|
63
|
+
/** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
|
|
64
|
+
private readonly reports = new Set<string>()
|
|
63
65
|
|
|
64
66
|
constructor(private readonly ctx: Context) {}
|
|
65
67
|
|
|
@@ -147,10 +149,64 @@ export class JobSessions {
|
|
|
147
149
|
/** Attach the `foreman_report` tool to a job Session, disposed with it. */
|
|
148
150
|
reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void {
|
|
149
151
|
const registrar = agent.ctx.tools
|
|
150
|
-
|
|
152
|
+
// Wrap the reporter so this Session's own turn ending can tell whether a report ever went out.
|
|
153
|
+
const reported = this.reports
|
|
154
|
+
const report = (id: string, deviceReport: DeviceReport): void => { reported.add(id); onReport(id, deviceReport) }
|
|
155
|
+
const tool = foremanReportTool({ report }, assignmentId)
|
|
151
156
|
registrar.register({ name: tool.name, description: tool.description, parameters: tool.parameters,
|
|
152
157
|
output: { schema: { type: 'object', additionalProperties: true },
|
|
153
158
|
render: (_args: unknown, value: unknown) => [{ type: 'text' as const, text: JSON.stringify(value) }] },
|
|
154
159
|
execute: (args: unknown, execution: { signal: AbortSignal }) => tool.execute(args as Record<string, unknown>, execution) })
|
|
160
|
+
this.watchTurnEnd(agent, assignmentId, report)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Report for a job that ended without calling the tool.
|
|
165
|
+
*
|
|
166
|
+
* Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
|
|
167
|
+
* answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
|
|
168
|
+
* failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
|
|
169
|
+
* as the summary. A person still gets the result; the ledger still learns the job ended.
|
|
170
|
+
*/
|
|
171
|
+
private watchTurnEnd(agent: Agent, assignmentId: string, report: (id: string, r: DeviceReport) => void): void {
|
|
172
|
+
// The watcher is a safety net, not part of the tool: a host that cannot register it must still get the tool,
|
|
173
|
+
// because a job with no reporting channel at all is worse than one whose turn end is not observed.
|
|
174
|
+
if (typeof agent.ctx.effect !== 'function') return
|
|
175
|
+
agent.ctx.effect(() => {
|
|
176
|
+
const dispose = agent.ctx.on('session/event', (session, event) => {
|
|
177
|
+
if (session.id !== agent.session.id || event.type !== 'turn/end') return
|
|
178
|
+
if (this.reports.has(assignmentId)) return
|
|
179
|
+
// Reading the history is best effort: an unreadable history still closes the job out, because leaving the
|
|
180
|
+
// hub at `running` forever is the worse failure.
|
|
181
|
+
void this.lastText(agent).then(text => {
|
|
182
|
+
if (this.reports.has(assignmentId)) return
|
|
183
|
+
report(assignmentId, { state: 'ready', summary: text === '' ? 'the job finished without a report' : text.slice(0, 600) })
|
|
184
|
+
}, () => {
|
|
185
|
+
if (this.reports.has(assignmentId)) return
|
|
186
|
+
report(assignmentId, { state: 'ready', summary: 'the job finished without a report' })
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
return () => { void dispose }
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The last text the assistant said in a Session, read from its stored history. */
|
|
194
|
+
private async lastText(agent: Agent): Promise<string> {
|
|
195
|
+
await this.ctx.sessionPersistence.flush()
|
|
196
|
+
await using handle = await this.ctx.sessionPersistence.open(agent.id, 'read')
|
|
197
|
+
let found = ''
|
|
198
|
+
for (let offset = 0; ;) {
|
|
199
|
+
const { events } = await handle.read(offset, 512)
|
|
200
|
+
if (events.length === 0) break
|
|
201
|
+
for (const event of events) {
|
|
202
|
+
if (event.type !== 'assistant/message') continue
|
|
203
|
+
const parts = (event.data as unknown as { message?: { content?: readonly { type?: string; text?: string }[] } })
|
|
204
|
+
?.message?.content ?? []
|
|
205
|
+
const text = parts.filter(part => part?.type === 'text').map(part => part.text ?? '').join(' ').trim()
|
|
206
|
+
if (text !== '') found = text
|
|
207
|
+
}
|
|
208
|
+
offset += events.length
|
|
209
|
+
}
|
|
210
|
+
return found
|
|
155
211
|
}
|
|
156
212
|
}
|