acrylblends 0.9.1 → 0.10.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.
@@ -1,6 +1,6 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import type {} from './memory.ts'
3
- import type {} from './model.ts'
3
+ import type { Step } from './model.ts'
4
4
  import type {} from './tools.ts'
5
5
 
6
6
  /** One tool call made during a run. */
@@ -20,8 +20,14 @@ export type RunResult = (
20
20
  | { readonly status: 'cancelled' }
21
21
  ) & { readonly steps: number; readonly trace: readonly ToolCall[]; readonly task?: string }
22
22
 
23
+ /** What a caller may pass to one run. */
24
+ export interface RunOptions {
25
+ /** Aborting it stops the run: the model call or tool in flight is dropped and the run ends `{ status: 'cancelled' }`. */
26
+ readonly signal?: AbortSignal
27
+ }
28
+
23
29
  export interface Agent {
24
- run(question: string): Promise<RunResult>
30
+ run(question: string, options?: RunOptions): Promise<RunResult>
25
31
  }
26
32
 
27
33
  /** The step budget of one run, provided inside the run's own fiber. */
@@ -62,6 +68,19 @@ declare module '@deepseek-ai/cordis' {
62
68
 
63
69
  const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
64
70
 
71
+ /** Thrown inside a run when its signal aborts; the run turns it into `cancelled`. */
72
+ class Cancelled extends Error {}
73
+
74
+ /** The promise, or a rejection with `Cancelled` as soon as the signal aborts (a model or tool that ignores the signal cannot hold the run). */
75
+ function until<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
76
+ if (signal.aborted) return Promise.reject(new Cancelled())
77
+ return new Promise<T>((resolve, reject) => {
78
+ const onAbort = () => reject(new Cancelled())
79
+ signal.addEventListener('abort', onAbort, { once: true })
80
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
81
+ })
82
+ }
83
+
65
84
  /**
66
85
  * The agent. Every run is its own child fiber that holds the run's budget and is disposed when the run ends, however it ends: a
67
86
  * scope per task, written with plain Cordis plugins. A tool that throws does not end the run: the model sees the error as the result.
@@ -74,11 +93,15 @@ export const agent = {
74
93
  const limit = config?.steps ?? 6
75
94
  let runs = 0
76
95
  ctx.provide('agent', {
77
- async run(question) {
96
+ async run(question, options) {
78
97
  runs += 1
79
98
  const trace: ToolCall[] = []
80
99
  const recorder = (ctx.get as (name: string) => unknown)('tasks') as TaskRecorder | undefined
81
100
  const record = recorder?.start('agent-run', { question })
101
+ // The run stops when its task is cancelled or the caller's signal aborts.
102
+ const signal = AbortSignal.any(
103
+ [record?.signal, options?.signal].filter((s): s is AbortSignal => s !== undefined),
104
+ )
82
105
  // Assigned inside the run's fiber; `as` keeps TypeScript from narrowing it to this first value.
83
106
  let result = { status: 'budget_exceeded', steps: 0, trace } as RunResult
84
107
  const task = ctx.plugin({
@@ -89,17 +112,28 @@ export const agent = {
89
112
  const budget = scope.budget
90
113
  let toolResult: unknown
91
114
  while (budget.steps < budget.limit) {
92
- if (record?.signal.aborted) {
115
+ if (signal.aborted) {
116
+ result = { status: 'cancelled', steps: budget.steps, trace }
117
+ return
118
+ }
119
+ let step: Step
120
+ try {
121
+ step = await until(
122
+ scope.model.complete({
123
+ question,
124
+ step: budget.steps,
125
+ toolResult,
126
+ trace: [...trace],
127
+ tools: scope.tools.list(),
128
+ signal,
129
+ }),
130
+ signal,
131
+ )
132
+ } catch (error) {
133
+ if (!(error instanceof Cancelled)) throw error
93
134
  result = { status: 'cancelled', steps: budget.steps, trace }
94
135
  return
95
136
  }
96
- const step = await scope.model.complete({
97
- question,
98
- step: budget.steps,
99
- toolResult,
100
- trace: [...trace],
101
- tools: scope.tools.list(),
102
- })
103
137
  budget.steps += 1
104
138
  if (step.tool === undefined) {
105
139
  scope.memory.remember(`${question} => ${step.text}`)
@@ -110,7 +144,10 @@ export const agent = {
110
144
  ? recorder?.start(`tool:${step.tool}`, step.input, { parent: record.id })
111
145
  : undefined
112
146
  try {
113
- toolResult = await scope.tools.call(step.tool, step.input)
147
+ toolResult = await until(
148
+ scope.tools.call(step.tool, step.input, { signal }),
149
+ signal,
150
+ )
114
151
  trace.push({
115
152
  tool: step.tool,
116
153
  input: step.input,
@@ -119,6 +156,12 @@ export const agent = {
119
156
  })
120
157
  call?.complete(toolResult)
121
158
  } catch (error) {
159
+ if (error instanceof Cancelled) {
160
+ trace.push({ tool: step.tool, input: step.input, error: 'cancelled' })
161
+ call?.fail('the run was cancelled')
162
+ result = { status: 'cancelled', steps: budget.steps, trace }
163
+ return
164
+ }
122
165
  toolResult = { error: message(error) }
123
166
  trace.push({
124
167
  tool: step.tool,
@@ -143,7 +186,10 @@ export const agent = {
143
186
  }
144
187
  if (result.status === 'completed')
145
188
  record?.complete(result.output, { steps: result.steps, trace })
146
- else if (result.status === 'budget_exceeded')
189
+ else if (result.status === 'cancelled') {
190
+ if (!record?.signal.aborted)
191
+ record?.fail('the run was cancelled', { steps: result.steps, trace })
192
+ } else if (result.status === 'budget_exceeded')
147
193
  record?.fail('the run used its whole step budget', { steps: result.steps, trace })
148
194
  return record ? { ...result, task: record.id } : result
149
195
  },
@@ -22,6 +22,8 @@ export interface ModelInput {
22
22
  }>
23
23
  /** The tools the model may call. */
24
24
  readonly tools?: ReadonlyArray<{ readonly name: string; readonly description: string }>
25
+ /** Aborts when the run is cancelled: a provider that can stop its request passes this on. */
26
+ readonly signal?: AbortSignal
25
27
  }
26
28
 
27
29
  /** What the agent needs from a model. Replace this plugin with a real provider when you have one. */
@@ -3,7 +3,8 @@ import type { Context } from '@deepseek-ai/cordis'
3
3
  /** One tool: what it does (for the model) and how to run it. */
4
4
  export interface Tool {
5
5
  readonly description: string
6
- run(input: unknown): unknown | Promise<unknown>
6
+ /** `options.signal` aborts when the run is cancelled; a tool that can stop its work listens to it. */
7
+ run(input: unknown, options?: { signal?: AbortSignal }): unknown | Promise<unknown>
7
8
  }
8
9
 
9
10
  /** The tool registry. A plugin registers its tools inside `ctx.effect`, so they leave with it. */
@@ -11,7 +12,7 @@ export interface Tools {
11
12
  /** Register a tool. Returns the function that removes it. */
12
13
  register(name: string, tool: Tool): () => void
13
14
  list(): Array<{ name: string; description: string }>
14
- call(name: string, input: unknown): Promise<unknown>
15
+ call(name: string, input: unknown, options?: { signal?: AbortSignal }): Promise<unknown>
15
16
  }
16
17
 
17
18
  declare module '@deepseek-ai/cordis' {
@@ -31,10 +32,10 @@ export function tools(ctx: Context) {
31
32
  },
32
33
  list: () =>
33
34
  [...table.entries()].map(([name, tool]) => ({ name, description: tool.description })),
34
- async call(name, input) {
35
+ async call(name, input, options) {
35
36
  const tool = table.get(name)
36
37
  if (!tool) throw new Error(`unknown tool "${name}"`)
37
- return await tool.run(input)
38
+ return await tool.run(input, options)
38
39
  },
39
40
  }
40
41
  ctx.provide('tools', registry)
@@ -4,7 +4,7 @@ const PREFIX = '/api/agent-panel'
4
4
 
5
5
  interface Session {
6
6
  token: string
7
- drivers: string[]
7
+ drivers: Array<{ name: string; kind: 'chat' | 'terminal' }>
8
8
  }
9
9
  interface PanelEvent {
10
10
  event: string
@@ -42,6 +42,8 @@ section { position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 100vw)
42
42
  section.shown { display: flex }
43
43
  header { display: flex; gap: 8px; align-items: center; padding: 8px 12px; border-bottom: 1px solid #e5e7eb }
44
44
  header strong { flex: 1 }
45
+ #term { flex: 1; min-height: 0; background: #000 }
46
+ #term[hidden], form[hidden], #log[hidden] { display: none }
45
47
  #log { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px }
46
48
  .msg { white-space: pre-wrap; padding: 8px 10px; border-radius: 8px; background: #f3f4f6 }
47
49
  .me { background: #dbeafe; align-self: flex-end }
@@ -65,8 +67,13 @@ async function start(): Promise<void> {
65
67
  shadow.innerHTML = `<style>${css}</style>
66
68
  <button class="open" type="button">Ask the agent</button>
67
69
  <section aria-label="Agent panel">
68
- <header><strong>Agent</strong><select aria-label="Agent">${session.drivers.map((d) => `<option>${d}</option>`).join('')}</select><button class="close" type="button" aria-label="Close">×</button></header>
70
+ <header><strong>Agent</strong><select aria-label="Agent">${session.drivers
71
+ .map((d) => `<option>${d.name}</option>`)
72
+ .join(
73
+ '',
74
+ )}</select><button class="termstop" type="button" hidden>Stop terminal</button><button class="close" type="button" aria-label="Close">×</button></header>
69
75
  <div id="log" role="log"></div>
76
+ <div id="term" hidden></div>
70
77
  <form><textarea aria-label="Message" placeholder="What should the app do?"></textarea>
71
78
  <div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
72
79
  </section>`
@@ -77,6 +84,54 @@ async function start(): Promise<void> {
77
84
  const send = $<HTMLButtonElement>('.send')
78
85
  const stop = $<HTMLButtonElement>('.stop')
79
86
  const driver = $<HTMLSelectElement>('select')
87
+ const term = $<HTMLElement>('#term')
88
+ const termStop = $<HTMLButtonElement>('.termstop')
89
+ termStop.addEventListener('click', () => void terminal?.stop())
90
+ const form = $<HTMLFormElement>('form')
91
+ const kindOf = (name: string): 'chat' | 'terminal' =>
92
+ session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
93
+ let terminal: { detach(): void; stop(): Promise<void> } | undefined
94
+ // A terminal driver swaps the chat for the driver's own terminal (loaded from the plugin that provides it, only when asked for).
95
+ const choose = async (): Promise<void> => {
96
+ terminal?.detach()
97
+ terminal = undefined
98
+ const isTerminal = kindOf(driver.value) === 'terminal'
99
+ log.hidden = isTerminal
100
+ form.hidden = isTerminal
101
+ term.hidden = !isTerminal
102
+ termStop.hidden = !isTerminal
103
+ if (!isTerminal) return
104
+ try {
105
+ const url = '/api/driver-pty/client/terminal.ts'
106
+ const { openTerminal } = (await import(url)) as {
107
+ openTerminal(options: {
108
+ container: HTMLElement
109
+ token: string
110
+ driver: string
111
+ status(text: string): void
112
+ onChange(change: { extension: string; action: string }): void
113
+ }): Promise<{ detach(): void; stop(): Promise<void> }>
114
+ }
115
+ terminal = await openTerminal({
116
+ container: term,
117
+ token: session.token,
118
+ driver: driver.value,
119
+ status: (text) => line(text, 'tool'),
120
+ onChange: (change) => {
121
+ line(`plugin ${change.extension}: ${change.action}`, 'tool')
122
+ line('Reloading the page to show it.', 'tool')
123
+ setTimeout(() => location.reload(), 900)
124
+ },
125
+ })
126
+ } catch (error) {
127
+ line(
128
+ `The terminal could not open: ${error instanceof Error ? error.message : String(error)}`,
129
+ 'err',
130
+ )
131
+ log.hidden = false
132
+ }
133
+ }
134
+ driver.addEventListener('change', () => void choose())
80
135
  const show = (open: boolean): void => {
81
136
  panel.classList.toggle('shown', open)
82
137
  if (open) input.focus()
@@ -154,6 +209,19 @@ async function start(): Promise<void> {
154
209
  }
155
210
  })
156
211
  document.body.append(host)
212
+ // A terminal that was open before a reload is still running on the server: show it again.
213
+ try {
214
+ const remembered = JSON.parse(sessionStorage.getItem('driver-pty-session') ?? 'null') as {
215
+ driver?: string
216
+ } | null
217
+ if (remembered?.driver && kindOf(remembered.driver) === 'terminal') {
218
+ driver.value = remembered.driver
219
+ show(true)
220
+ void choose()
221
+ }
222
+ } catch {
223
+ // No storage: nothing to show again.
224
+ }
157
225
  }
158
226
 
159
227
  void start()
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The drivers the agent panel can offer, in one registry (the `drivers` service the panel provides). A driver is one of two kinds of session:
3
+ *
4
+ * - `chat`: a message in, the panel's own events out (`text`, `tool`, `error`). The app's agent, the headless coding agents and a harness chat are chat drivers.
5
+ * - `terminal`: bytes in, bytes out, a real pseudo-terminal (the `driver-pty` plugin). Nothing is forced to speak bytes.
6
+ *
7
+ * The panel lists every registered driver and picks the view by `kind`. A plugin that provides drivers injects `drivers`: with no panel mounted it is pending,
8
+ * and a panel without such a plugin simply lists none of its drivers.
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+
12
+ /** One event of a chat run. The panel adds `change` (a plugin was installed or removed) and `done` itself. */
13
+ export type ChatEvent =
14
+ | { readonly event: 'text'; readonly data: { readonly text: string } }
15
+ | { readonly event: 'tool'; readonly data: Record<string, unknown> }
16
+ | { readonly event: 'error'; readonly data: { readonly message: string } }
17
+
18
+ export interface ChatDriver {
19
+ readonly kind: 'chat'
20
+ readonly name: string
21
+ /** Runs one message. Aborting `signal` stops the run. */
22
+ start(options: { message: string; signal: AbortSignal }): AsyncIterable<ChatEvent>
23
+ }
24
+
25
+ /** A live pseudo-terminal session. */
26
+ export interface TerminalSession {
27
+ readonly id: string
28
+ onData(listener: (chunk: string) => void): () => void
29
+ onExit(listener: (code: number | null) => void): () => void
30
+ write(data: string): void
31
+ resize(cols: number, rows: number): void
32
+ /** Resolves when the process (group) is gone. */
33
+ kill(): Promise<void>
34
+ }
35
+
36
+ export interface TerminalDriver {
37
+ readonly kind: 'terminal'
38
+ readonly name: string
39
+ start(options: {
40
+ cwd: string
41
+ env: NodeJS.ProcessEnv
42
+ cols: number
43
+ rows: number
44
+ }): TerminalSession
45
+ }
46
+
47
+ export type Driver = ChatDriver | TerminalDriver
48
+
49
+ export interface Drivers {
50
+ /** Add a driver. Returns the function that removes it; a plugin registers inside `ctx.effect` so the driver leaves with it. */
51
+ register(driver: Driver): () => void
52
+ list(): Driver[]
53
+ get(name: string): Driver | undefined
54
+ /** Whether a request may use the drivers' endpoints: it comes from the app's own page and carries the panel's token. */
55
+ authorized(headers: Headers): boolean
56
+ }
57
+
58
+ declare module '@deepseek-ai/cordis' {
59
+ interface Context {
60
+ drivers: Drivers
61
+ }
62
+ }
63
+
64
+ export function createDrivers(authorized: (headers: Headers) => boolean = () => true): Drivers {
65
+ const table = new Map<string, Driver>()
66
+ return {
67
+ register(driver) {
68
+ if (table.has(driver.name)) throw new Error(`driver "${driver.name}" is already registered`)
69
+ table.set(driver.name, driver)
70
+ return () => void (table.get(driver.name) === driver && table.delete(driver.name))
71
+ },
72
+ list: () => [...table.values()],
73
+ get: (name) => table.get(name),
74
+ authorized,
75
+ }
76
+ }
77
+
78
+ /** Provide the registry on `ctx` (the panel does). */
79
+ export function provideDrivers(ctx: Context, authorized: (headers: Headers) => boolean): Drivers {
80
+ const registry = createDrivers(authorized)
81
+ ;(ctx as Context).provide('drivers', registry)
82
+ return registry
83
+ }
@@ -0,0 +1,154 @@
1
+ /** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
2
+ import { spawn, spawnSync } from 'node:child_process'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { ChatDriver, ChatEvent } from './drivers.ts'
5
+
6
+ export type HeadlessName = 'claude' | 'codex' | 'pi'
7
+
8
+ /** The command line of each coding agent started headless in the app folder (the same ones the product test uses). */
9
+ export function command(
10
+ driver: HeadlessName,
11
+ message: string,
12
+ root: string,
13
+ ): { file: string; args: string[] } {
14
+ switch (driver) {
15
+ case 'claude':
16
+ return {
17
+ file: 'claude',
18
+ args: [
19
+ '-p',
20
+ message,
21
+ '--permission-mode',
22
+ 'acceptEdits',
23
+ '--allowedTools',
24
+ 'Read,Edit,Write,Glob,Grep,Bash(npm *),Bash(npx *),Bash(node *),Bash(.acryl/bin/acryl *),Bash(curl *)',
25
+ ],
26
+ }
27
+ case 'codex':
28
+ return {
29
+ file: 'codex',
30
+ args: [
31
+ 'exec',
32
+ '--skip-git-repo-check',
33
+ '-s',
34
+ 'workspace-write',
35
+ '-c',
36
+ 'sandbox_workspace_write.network_access=true',
37
+ '-C',
38
+ root,
39
+ message,
40
+ ],
41
+ }
42
+ case 'pi':
43
+ return {
44
+ file: 'pi',
45
+ args: [
46
+ '-p',
47
+ message,
48
+ '--provider',
49
+ 'deepseek',
50
+ '--model',
51
+ 'deepseek-v4-pro',
52
+ '--no-session',
53
+ ],
54
+ }
55
+ }
56
+ }
57
+
58
+ export function onPath(program: string): boolean {
59
+ const found = spawnSync(process.platform === 'win32' ? 'where' : 'which', [program], {
60
+ encoding: 'utf8',
61
+ })
62
+ return found.status === 0
63
+ }
64
+
65
+ /** The headless programs this machine can offer: installed (pi also needs its key in the environment). */
66
+ export function availableHeadless(wanted?: readonly HeadlessName[]): HeadlessName[] {
67
+ const found: HeadlessName[] = []
68
+ for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
69
+ if (driver === 'pi' && !process.env.DEEPSEEK_API_KEY) continue
70
+ if (onPath(driver)) found.push(driver)
71
+ }
72
+ return found
73
+ }
74
+
75
+ /** The app's own agent as a chat driver: its tool calls, then its answer. Aborting the signal cancels the run. */
76
+ export function agentDriver(ctx: Context): ChatDriver {
77
+ return {
78
+ kind: 'chat',
79
+ name: 'agent',
80
+ async *start({ message, signal }) {
81
+ const result = await ctx.agent.run(message, { signal })
82
+ for (const call of result.trace)
83
+ yield {
84
+ event: 'tool',
85
+ data: {
86
+ tool: call.tool,
87
+ input: call.input,
88
+ ...(call.error ? { error: call.error } : { result: call.result }),
89
+ },
90
+ }
91
+ yield {
92
+ event: 'text',
93
+ data: {
94
+ text:
95
+ result.status === 'completed'
96
+ ? result.output
97
+ : result.status === 'cancelled'
98
+ ? 'The run was cancelled.'
99
+ : `The run stopped after ${result.steps} steps without an answer.`,
100
+ },
101
+ }
102
+ },
103
+ }
104
+ }
105
+
106
+ /** A coding agent run headless in the app folder: its stdout is the answer, its stderr the tool lines. Aborting the signal ends the program. */
107
+ export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
108
+ return {
109
+ kind: 'chat',
110
+ name,
111
+ async *start({ message, signal }) {
112
+ const { file, args } = command(name, message, root)
113
+ const child = spawn(file, args, {
114
+ cwd: root,
115
+ env: process.env,
116
+ stdio: ['ignore', 'pipe', 'pipe'],
117
+ })
118
+ const queue: ChatEvent[] = []
119
+ let wake: (() => void) | undefined
120
+ let ended = false
121
+ const push = (event: ChatEvent): void => {
122
+ queue.push(event)
123
+ wake?.()
124
+ }
125
+ signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true })
126
+ child.stdout.on('data', (chunk: Buffer) =>
127
+ push({ event: 'text', data: { text: chunk.toString() } }),
128
+ )
129
+ child.stderr.on('data', (chunk: Buffer) =>
130
+ push({ event: 'tool', data: { tool: name, info: chunk.toString().slice(0, 500) } }),
131
+ )
132
+ child.on('error', () => {
133
+ ended = true
134
+ push({ event: 'error', data: { message: `${name} could not be started` } })
135
+ })
136
+ child.on('exit', (code) => {
137
+ ended = true
138
+ if (code !== 0 && !signal.aborted)
139
+ push({ event: 'error', data: { message: `${name} ended with exit code ${code ?? 1}` } })
140
+ else wake?.()
141
+ })
142
+ try {
143
+ for (;;) {
144
+ const next = queue.shift()
145
+ if (next) yield next
146
+ else if (ended) return
147
+ else await new Promise<void>((resolve) => (wake = resolve))
148
+ }
149
+ } finally {
150
+ if (child.exitCode === null) child.kill('SIGTERM')
151
+ }
152
+ },
153
+ }
154
+ }