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.
@@ -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
+ }