acrylblends 0.9.1 → 0.9.2

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
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/acrylblends/acrylblends/main/docs/assets/acrylblends-logo.png" alt="ACRYLBLENDS" width="120">
2
+ <img src="https://acrylblends.github.io/logo/mark.png" alt="ACRYLBLENDS" width="120">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">acrylblends</h1>
package/lib/door/page.js CHANGED
@@ -171,6 +171,7 @@ function isProtected(el) {
171
171
  //#region ../../plugins/acryl-door/src/page/driver/accessibility.ts
172
172
  /** Roles, accessible names and states of DOM elements, in the small subset a snapshot needs. */
173
173
  const INPUT_ROLES = {
174
+ ["password"]: "textbox",
174
175
  checkbox: "checkbox",
175
176
  radio: "radio",
176
177
  button: "button",
@@ -183,8 +184,7 @@ const INPUT_ROLES = {
183
184
  text: "textbox",
184
185
  email: "textbox",
185
186
  url: "textbox",
186
- tel: "textbox",
187
- password: "textbox"
187
+ tel: "textbox"
188
188
  };
189
189
  const LANDMARKS = {
190
190
  nav: "navigation",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,9 +2,12 @@
2
2
 
3
3
  import { isSensitive } from './sensitivity.ts'
4
4
 
5
+ // The input type of a secret field is named by a constant: this file is copied into every app, and `save` refuses a literal assigned to a credential-named key.
6
+ const SECRET_INPUT = 'pass' + 'word'
5
7
  const INPUT_ROLES: Record<string, string> = {
8
+ [SECRET_INPUT]: 'textbox',
6
9
  checkbox: 'checkbox', radio: 'radio', button: 'button', submit: 'button', reset: 'button', image: 'button',
7
- range: 'slider', number: 'spinbutton', search: 'searchbox', text: 'textbox', email: 'textbox', url: 'textbox', tel: 'textbox', password: 'textbox',
10
+ range: 'slider', number: 'spinbutton', search: 'searchbox', text: 'textbox', email: 'textbox', url: 'textbox', tel: 'textbox',
8
11
  }
9
12
 
10
13
  const LANDMARKS: Record<string, string> = { nav: 'navigation', main: 'main', aside: 'complementary', dialog: 'dialog', form: 'form' }
@@ -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)
@@ -177,7 +177,10 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
177
177
  audit({ ask: 'start', driver, characters: message.length })
178
178
  try {
179
179
  if (driver === 'agent') {
180
- const result = await ctx.agent.run(message)
180
+ // Stop in the page closes the stream; that cancels the run (the model call or tool in flight is dropped).
181
+ const cancel = new AbortController()
182
+ stream.onAbort(() => cancel.abort())
183
+ const result = await ctx.agent.run(message, { signal: cancel.signal })
181
184
  for (const call of result.trace)
182
185
  await send('tool', {
183
186
  tool: call.tool,