dsh-foreman-node 0.0.0-stage → 0.1.0
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 +39 -0
- package/LICENSE +21 -0
- package/README.md +78 -2
- package/cordis.patch.yml +5 -0
- package/lib/index.d.ts +219 -0
- package/lib/index.js +573 -0
- package/lib/protocol.d.ts +110 -0
- package/lib/protocol.js +69 -0
- package/package.json +94 -4
- package/src/client.ts +209 -0
- package/src/index.ts +129 -0
- package/src/protocol.ts +160 -0
- package/src/report-tool.ts +93 -0
- package/src/session.ts +156 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `foreman_report` tool a device registers on a job Session.
|
|
3
|
+
*
|
|
4
|
+
* Reporting is a hard requirement rather than a convenience: in the domain, "waiting for a person" is a structured
|
|
5
|
+
* fact that a report carries, and inferring it from a turn ending is an explicitly rejected approach. So a device
|
|
6
|
+
* states outcomes through this tool and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* `dropped` is not in its enum. Deciding that work should not be done is a judgement, a device has no judgement, and
|
|
9
|
+
* the hub's ledger already refuses that state from a device (see the protocol). A device that believes the same thing
|
|
10
|
+
* must report `blocked` and let a person decide.
|
|
11
|
+
*
|
|
12
|
+
* The schema is written as JSON Schema and validated here rather than through `zod`, so this package stays free of
|
|
13
|
+
* runtime dependencies beyond the DSH services it is a peer plugin of.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What a device may report; the same set the protocol's `NodeReportState` names. */
|
|
17
|
+
export const REPORT_STATES = ['progress', 'ready', 'blocked', 'failed'] as const
|
|
18
|
+
export type ReportState = typeof REPORT_STATES[number]
|
|
19
|
+
|
|
20
|
+
/** A report as the tool's caller gives it. */
|
|
21
|
+
export interface DeviceReport {
|
|
22
|
+
state: ReportState
|
|
23
|
+
summary: string
|
|
24
|
+
/** One question a person must answer; paired with `state: 'blocked'` in practice. */
|
|
25
|
+
question?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The host a tool reports through: the client that owns the connection, or a test. */
|
|
29
|
+
export interface ReportHost {
|
|
30
|
+
report(assignmentId: string, report: DeviceReport): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The parts of a tool definition DSH registers: name, description, JSON Schema, and the executor. */
|
|
34
|
+
export interface DeviceTool {
|
|
35
|
+
name: string
|
|
36
|
+
description: string
|
|
37
|
+
parameters: Record<string, unknown>
|
|
38
|
+
execute(args: Record<string, unknown>, execution: { signal: AbortSignal }): Promise<unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The JSON Schema a model sees, which is also what `execute` validates against. */
|
|
42
|
+
const PARAMETERS: Record<string, unknown> = {
|
|
43
|
+
type: 'object',
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
required: ['state', 'summary'],
|
|
46
|
+
properties: {
|
|
47
|
+
state: { type: 'string', enum: [...REPORT_STATES], description: 'progress while working, ready when the result is in, blocked when a person must act, failed when the work cannot be done.' },
|
|
48
|
+
summary: { type: 'string', minLength: 1, description: 'What was done, or what is needed, in one or two sentences.' },
|
|
49
|
+
question: { type: 'string', minLength: 1, description: 'One concrete question for a person, with state=blocked.' },
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Thrown for arguments this tool refuses; the model sees the message and can correct itself. */
|
|
54
|
+
class ReportRefusal extends Error {
|
|
55
|
+
constructor(message: string) { super(message); this.name = 'ReportRefusal' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Validate the arguments to a report, refusing `dropped`, an unknown state, or an empty summary. */
|
|
59
|
+
export function readReport(args: Record<string, unknown>): DeviceReport {
|
|
60
|
+
const state = args['state']
|
|
61
|
+
if (typeof state !== 'string') throw new ReportRefusal('state must be a string')
|
|
62
|
+
if (state === 'dropped') {
|
|
63
|
+
throw new ReportRefusal('a device may not report dropped: deciding that work should not be done is the hub\'s judgement, so report blocked instead')
|
|
64
|
+
}
|
|
65
|
+
if (!(REPORT_STATES as readonly string[]).includes(state)) {
|
|
66
|
+
throw new ReportRefusal(`state must be one of ${REPORT_STATES.join(', ')}, not ${state}`)
|
|
67
|
+
}
|
|
68
|
+
const summary = typeof args['summary'] === 'string' ? args['summary'].trim() : ''
|
|
69
|
+
if (summary === '') throw new ReportRefusal('summary must say what was done or what is needed')
|
|
70
|
+
const question = typeof args['question'] === 'string' ? args['question'].trim() : undefined
|
|
71
|
+
if (args['question'] !== undefined && (question === undefined || question === '')) {
|
|
72
|
+
throw new ReportRefusal('question must be a question when given')
|
|
73
|
+
}
|
|
74
|
+
return { state: state as ReportState, summary, ...(question === undefined ? {} : { question }) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the `foreman_report` tool for one assignment.
|
|
79
|
+
*
|
|
80
|
+
* The assignment id is bound here rather than passed by the model: a job Session reports on the assignment it was
|
|
81
|
+
* started for, and a model should not be able to name a different one.
|
|
82
|
+
*/
|
|
83
|
+
export function foremanReportTool(host: ReportHost, assignmentId: string): DeviceTool {
|
|
84
|
+
return {
|
|
85
|
+
name: 'foreman_report',
|
|
86
|
+
description: 'Report this job to the foreman: progress, ready, blocked or failed, with a one-sentence summary and, when a person must answer, one question.',
|
|
87
|
+
parameters: PARAMETERS,
|
|
88
|
+
async execute(args) {
|
|
89
|
+
host.report(assignmentId, readReport(args))
|
|
90
|
+
return { reported: true }
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job sessions on a device: one Session per assignment, in the directory the hub named.
|
|
3
|
+
*
|
|
4
|
+
* This is the device-side counterpart of `Sessions.ensure` on the hub, and it deliberately sets a different
|
|
5
|
+
* permission preset. A hub session runs with `danger-full-access` because a person is sitting in front of it and
|
|
6
|
+
* approves; a job session runs `workspace-write` inside a directory the device declared, because nobody is there to
|
|
7
|
+
* answer a prompt. Work that needs more is reported `blocked`, not silently elevated.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here touches the hub's domain, its database, or its policy: a device has no ledger.
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
13
|
+
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-api-session-controller'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-workspace'
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-permission-presets'
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-session-persistence'
|
|
18
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
19
|
+
import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
|
|
20
|
+
import { foremanReportTool, type DeviceReport } from './report-tool.ts'
|
|
21
|
+
|
|
22
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
23
|
+
interface MessageSourceMap {
|
|
24
|
+
'dsh-foreman-node': { kind: 'dsh-foreman-node'; form: 'relay' }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The permission preset a job runs under: bounded to the directory the device declared. */
|
|
29
|
+
export const JOB_PRESET = 'workspace-write'
|
|
30
|
+
|
|
31
|
+
/** The Session a job runs in, named the way a hub names its own worker: `job-<assignmentId>`. */
|
|
32
|
+
export const jobSessionId = (assignmentId: string): string => `job-${assignmentId}`
|
|
33
|
+
|
|
34
|
+
/** The message id of one delivery, derived from the hub's request id so a redelivery adds nothing. */
|
|
35
|
+
const requestMessage = (requestId: string): MessageId => MessageId(`dsh-foreman-node:${requestId}`)
|
|
36
|
+
|
|
37
|
+
export interface EnsureInput {
|
|
38
|
+
assignmentId: string
|
|
39
|
+
/** The absolute directory on this device; the hub mapped it through the device's declared roots. */
|
|
40
|
+
directory: string
|
|
41
|
+
title: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the tool registrar looks like, narrowed to what this file uses. */
|
|
45
|
+
export interface ToolRegistrar {
|
|
46
|
+
register(definition: { name: string; description: string; parameters: Record<string, unknown>
|
|
47
|
+
output: { schema: Record<string, unknown>; render: (args: unknown, value: unknown) => unknown }
|
|
48
|
+
execute: (args: unknown, execution: { signal: AbortSignal }) => Promise<unknown> }): unknown
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Keeps the device's job Sessions and delivers work into them.
|
|
53
|
+
*
|
|
54
|
+
* A Session is created only when it does not exist yet, so a redelivered `assign` reuses the Session and the work
|
|
55
|
+
* already in it instead of starting a second one. Delivery is idempotent by request id: the hub retrying a frame
|
|
56
|
+
* that it never saw acknowledged cannot append the task twice.
|
|
57
|
+
*/
|
|
58
|
+
export class JobSessions {
|
|
59
|
+
/** Jobs by assignment id, so `running()` reports what this device actually holds rather than what it was told. */
|
|
60
|
+
private readonly jobs = new Map<string, string>()
|
|
61
|
+
/** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
|
|
62
|
+
private readonly reported = new Set<string>()
|
|
63
|
+
|
|
64
|
+
constructor(private readonly ctx: Context) {}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
|
|
68
|
+
* register it so a heartbeat counts it.
|
|
69
|
+
*
|
|
70
|
+
* The tool is attached here rather than left to the caller because it is the only way a device can say what
|
|
71
|
+
* happened: without it a job Session's model has no `progress`/`ready`/`blocked`/`failed` channel, the hub waits
|
|
72
|
+
* forever, and the whole design's "never infer state from session events" requirement is moot. Registration is
|
|
73
|
+
* idempotent per assignment, so a redelivered `assign` does not stack a second tool on the same Session.
|
|
74
|
+
*
|
|
75
|
+
* @returns the Session id.
|
|
76
|
+
*/
|
|
77
|
+
async ensure(input: EnsureInput, onReport?: (assignmentId: string, report: DeviceReport) => void): Promise<string> {
|
|
78
|
+
const id = jobSessionId(input.assignmentId)
|
|
79
|
+
const workspace = await this.ctx.workspaceRegistry.create(input.directory)
|
|
80
|
+
let result = await this.ctx.sessionController.resolveAgent(id as SessionId)
|
|
81
|
+
if ('error' in result && result.error.code === 'session/not-found') {
|
|
82
|
+
await this.ctx.sessionController.create({ sessionId: id as SessionId, workspaceId: workspace.id })
|
|
83
|
+
result = await this.ctx.sessionController.resolveAgent(id as SessionId)
|
|
84
|
+
}
|
|
85
|
+
if ('error' in result) throw result.error
|
|
86
|
+
await workspace.attachSession(id as SessionId)
|
|
87
|
+
// The one place the device's policy differs from the hub's: bounded, because no person is here to approve more.
|
|
88
|
+
this.ctx.permissionPresets.set(result.agent.session, JOB_PRESET)
|
|
89
|
+
await this.ctx.sessionController.rename({ sessionId: id as SessionId, title: input.title })
|
|
90
|
+
if (onReport !== undefined && !this.reported.has(input.assignmentId)) {
|
|
91
|
+
this.reported.add(input.assignmentId)
|
|
92
|
+
try { this.reportTools(result.agent as Agent, input.assignmentId, onReport) }
|
|
93
|
+
catch (error) {
|
|
94
|
+
// A Session that refuses the tool would never report: forget the guard so a later assign retries it.
|
|
95
|
+
this.reported.delete(input.assignmentId)
|
|
96
|
+
throw new Error(`could not attach foreman_report to ${id}: ${String(error)}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.jobs.set(input.assignmentId, id)
|
|
100
|
+
return id
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deliver a task into its job Session, once per request id.
|
|
105
|
+
*
|
|
106
|
+
* `queue` rather than `steer` on the first delivery: the job's task should start a turn, not interrupt one that is
|
|
107
|
+
* already running. A redelivery finds the message already recorded and adds nothing.
|
|
108
|
+
*/
|
|
109
|
+
async deliver(sessionId: string, text: string, requestId: string): Promise<void> {
|
|
110
|
+
const result = await this.ctx.sessionController.resolveAgent(sessionId as SessionId)
|
|
111
|
+
if ('error' in result) throw result.error
|
|
112
|
+
const agent = result.agent as Agent
|
|
113
|
+
if (await this.holds(agent, requestId)) return
|
|
114
|
+
const source = { kind: 'dsh-foreman-node' as const, form: 'relay' as const }
|
|
115
|
+
agent.followup(freezeMessage({ id: requestMessage(requestId), role: 'user' as const, content: [{ type: 'text' as const, text }], source }))
|
|
116
|
+
await this.ctx.sessionPersistence.flush()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether the Session already holds the delivery for `requestId`, in its live inbox or its stored history. */
|
|
120
|
+
async holds(agent: Agent, requestId: string): Promise<boolean> {
|
|
121
|
+
const id = requestMessage(requestId)
|
|
122
|
+
if ([...agent.inbox.nextTurn, ...agent.inbox.nextStep].some(message => message.id === id)) return true
|
|
123
|
+
try {
|
|
124
|
+
await this.ctx.sessionPersistence.flush()
|
|
125
|
+
await using handle = await this.ctx.sessionPersistence.open(agent.id, 'read')
|
|
126
|
+
for (let offset = 0; ;) {
|
|
127
|
+
const { events } = await handle.read(offset, 512)
|
|
128
|
+
if (events.length === 0) return false
|
|
129
|
+
for (const event of events) {
|
|
130
|
+
if (event.type === 'user/message' && (event.data as { id?: string }).id === id) return true
|
|
131
|
+
}
|
|
132
|
+
offset += events.length
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// A Session whose history cannot be read is not proof of a delivery: deliver again rather than lose the task.
|
|
136
|
+
this.ctx.logger.warn('dsh-foreman-node could not read %s history: %s', agent.id, String(error))
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The assignment ids this device currently holds a job for: what a heartbeat and a reconnected hello report. */
|
|
142
|
+
running(): string[] { return [...this.jobs.keys()] }
|
|
143
|
+
|
|
144
|
+
/** Forget a job whose assignment the hub stopped, so this device stops reporting it as running. */
|
|
145
|
+
forget(assignmentId: string): void { this.jobs.delete(assignmentId) }
|
|
146
|
+
|
|
147
|
+
/** Attach the `foreman_report` tool to a job Session, disposed with it. */
|
|
148
|
+
reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void {
|
|
149
|
+
const registrar = agent.ctx.tools
|
|
150
|
+
const tool = foremanReportTool({ report: onReport }, assignmentId)
|
|
151
|
+
registrar.register({ name: tool.name, description: tool.description, parameters: tool.parameters,
|
|
152
|
+
output: { schema: { type: 'object', additionalProperties: true },
|
|
153
|
+
render: (_args: unknown, value: unknown) => [{ type: 'text' as const, text: JSON.stringify(value) }] },
|
|
154
|
+
execute: (args: unknown, execution: { signal: AbortSignal }) => tool.execute(args as Record<string, unknown>, execution) })
|
|
155
|
+
}
|
|
156
|
+
}
|