acrylblends 0.0.0-stage → 0.3.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/bin/acrylblends.js +4 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +454 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -4
- package/plugins/acryl-inspect/LICENSE +21 -0
- package/plugins/acryl-inspect/plugin.json +11 -0
- package/plugins/acryl-inspect/src/index.ts +3 -0
- package/plugins/acryl-inspect/src/report.ts +123 -0
- package/plugins/acryl-inspect/src/service.ts +66 -0
- package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
- package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
- package/plugins/acryl-inspect/upstream.json +18 -0
- package/plugins/acryl-node-server/LICENSE +21 -0
- package/plugins/acryl-node-server/plugin.json +12 -0
- package/plugins/acryl-node-server/src/index.ts +94 -0
- package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
- package/plugins/acryl-node-server/upstream.json +18 -0
- package/plugins/acryl-router/LICENSE +21 -0
- package/plugins/acryl-router/plugin.json +12 -0
- package/plugins/acryl-router/src/compose.ts +21 -0
- package/plugins/acryl-router/src/context.ts +59 -0
- package/plugins/acryl-router/src/helpers/index.ts +3 -0
- package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
- package/plugins/acryl-router/src/helpers/sse.ts +83 -0
- package/plugins/acryl-router/src/helpers/validate.ts +44 -0
- package/plugins/acryl-router/src/http-error.ts +21 -0
- package/plugins/acryl-router/src/index.ts +6 -0
- package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
- package/plugins/acryl-router/src/middleware/cors.ts +46 -0
- package/plugins/acryl-router/src/middleware/index.ts +6 -0
- package/plugins/acryl-router/src/middleware/logger.ts +21 -0
- package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
- package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
- package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
- package/plugins/acryl-router/src/pattern.ts +89 -0
- package/plugins/acryl-router/src/registrar.ts +90 -0
- package/plugins/acryl-router/src/service.ts +164 -0
- package/plugins/acryl-router/src/table.ts +156 -0
- package/plugins/acryl-router/src/types.ts +73 -0
- package/plugins/acryl-router/src/util.ts +23 -0
- package/plugins/acryl-router/tests/api.spec.ts +223 -0
- package/plugins/acryl-router/tests/conformance.ts +170 -0
- package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
- package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
- package/plugins/acryl-router/tests/loader.spec.ts +71 -0
- package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
- package/plugins/acryl-router/tests/service.spec.ts +211 -0
- package/plugins/acryl-router/tests/table.spec.ts +47 -0
- package/plugins/acryl-router/upstream.json +20 -0
- package/plugins/acryl-router-http/LICENSE +21 -0
- package/plugins/acryl-router-http/plugin.json +11 -0
- package/plugins/acryl-router-http/src/bridge.ts +52 -0
- package/plugins/acryl-router-http/src/index.ts +63 -0
- package/plugins/acryl-router-http/src/serve.ts +24 -0
- package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
- package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
- package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
- package/plugins/acryl-router-http/upstream.json +22 -0
- package/plugins/acryl-state/LICENSE +21 -0
- package/plugins/acryl-state/plugin.json +12 -0
- package/plugins/acryl-state/src/cell.ts +67 -0
- package/plugins/acryl-state/src/index.ts +3 -0
- package/plugins/acryl-state/src/react.ts +11 -0
- package/plugins/acryl-state/src/service.ts +101 -0
- package/plugins/acryl-state/tests/cell.spec.ts +71 -0
- package/plugins/acryl-state/tests/react.spec.ts +39 -0
- package/plugins/acryl-state/tests/service.spec.ts +132 -0
- package/plugins/acryl-state/upstream.json +20 -0
- package/plugins/agent/agent.ts +152 -0
- package/plugins/agent/index.ts +20 -0
- package/plugins/agent/memory.ts +24 -0
- package/plugins/agent/model.ts +79 -0
- package/plugins/agent/plugin.json +10 -0
- package/plugins/agent/tools.ts +50 -0
- package/plugins/builder/index.ts +193 -0
- package/plugins/builder/plugin.json +11 -0
- package/plugins/builder/script.ts +161 -0
- package/plugins/extensions/index.ts +755 -0
- package/plugins/extensions/ledger.ts +39 -0
- package/plugins/extensions/plugin.json +12 -0
- package/plugins/extensions/resolve.ts +30 -0
- package/plugins/extensions/stage.ts +44 -0
- package/plugins/instance/index.ts +167 -0
- package/plugins/instance/plugin.json +10 -0
- package/plugins/manifest/index.ts +635 -0
- package/plugins/manifest/lock.ts +94 -0
- package/plugins/manifest/plugin.json +13 -0
- package/plugins/manifest/upgrade-cli.ts +70 -0
- package/plugins/manifest/upgrade.ts +97 -0
- package/plugins/openai/index.ts +159 -0
- package/plugins/openai/plugin.json +11 -0
- package/plugins/openai/prompt.ts +35 -0
- package/plugins/sandbox/index.ts +150 -0
- package/plugins/sandbox/plugin.json +8 -0
- package/plugins/sandbox/runner.mjs +18 -0
- package/plugins/save/cli.ts +19 -0
- package/plugins/save/index.ts +55 -0
- package/plugins/save/plugin.json +12 -0
- package/plugins/settings/index.ts +270 -0
- package/plugins/settings/merge.ts +79 -0
- package/plugins/settings/plugin.json +13 -0
- package/plugins/tasks/index.ts +246 -0
- package/plugins/tasks/plugin.json +11 -0
- package/plugins/tasks/store.ts +86 -0
- package/plugins/tsconfig.json +27 -0
- package/templates/agent/Dockerfile +15 -0
- package/templates/agent/README.md +52 -0
- package/templates/agent/blend.yaml +56 -0
- package/templates/agent/dockerignore +3 -0
- package/templates/agent/gitignore +2 -0
- package/templates/agent/package.json +23 -0
- package/templates/agent/src/app.ts +98 -0
- package/templates/agent/src/main.ts +25 -0
- package/templates/agent/src/routes.ts +53 -0
- package/templates/agent/template.json +16 -0
- package/templates/agent/test/agent.test.ts +45 -0
- package/templates/agent/test/app.test.ts +46 -0
- package/templates/agent/test/builder.test.ts +194 -0
- package/templates/agent/test/model.test.ts +88 -0
- package/templates/agent/tsconfig.json +19 -0
- package/templates/blank/README.md +26 -0
- package/templates/blank/blend.yaml +23 -0
- package/templates/blank/gitignore +2 -0
- package/templates/blank/package.json +23 -0
- package/templates/blank/src/app.ts +61 -0
- package/templates/blank/src/main.ts +20 -0
- package/templates/blank/src/routes.ts +14 -0
- package/templates/blank/template.json +1 -0
- package/templates/blank/test/app.test.ts +56 -0
- package/templates/blank/tsconfig.json +19 -0
- package/templates/todo/README.md +27 -0
- package/templates/todo/blend.yaml +25 -0
- package/templates/todo/gitignore +2 -0
- package/templates/todo/package.json +23 -0
- package/templates/todo/src/app.ts +71 -0
- package/templates/todo/src/main.ts +19 -0
- package/templates/todo/src/page.ts +28 -0
- package/templates/todo/src/todo.ts +96 -0
- package/templates/todo/template.json +1 -0
- package/templates/todo/test/todo.test.ts +145 -0
- package/templates/todo/tsconfig.json +19 -0
- package/templates/todo/upstream.json +12 -0
- package/README.md +0 -3
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACRYL state service (`ctx.state`): observable cells owned by plugins. A cell created from a plugin's context is removed
|
|
3
|
+
* when that plugin unloads, so a surface never keeps a value alive for a plugin that is gone. In process the cells are read
|
|
4
|
+
* and subscribed to directly (the terminal UI); with a router present they are also readable as JSON and streamable as
|
|
5
|
+
* server-sent events (Web and Desktop), read-only: only the Host plugin that owns a cell writes it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Context, Service } from '@deepseek-ai/cordis'
|
|
9
|
+
import type { RouteContext } from '../../acryl-router/src/index.ts'
|
|
10
|
+
import { streamSSE } from '../../acryl-router/src/helpers/index.ts'
|
|
11
|
+
import { loopbackOnly } from '../../acryl-router/src/middleware/index.ts'
|
|
12
|
+
import { createCell, createDerived, type Cell, type ReadonlyCell } from './cell.ts'
|
|
13
|
+
|
|
14
|
+
declare module '@deepseek-ai/cordis' {
|
|
15
|
+
interface Context {
|
|
16
|
+
state: State
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
21
|
+
export const name = 'acryl-state'
|
|
22
|
+
|
|
23
|
+
const KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/
|
|
24
|
+
|
|
25
|
+
export class State extends Service {
|
|
26
|
+
private readonly cells = new Map<string, ReadonlyCell<unknown>>()
|
|
27
|
+
|
|
28
|
+
constructor(ctx: Context) {
|
|
29
|
+
super(ctx, 'state')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private claim(key: string): void {
|
|
33
|
+
if (!KEY_PATTERN.test(key)) throw new TypeError(`acryl-state: key "${key}" must be dotted lowercase words, like "market.sources"`)
|
|
34
|
+
if (this.cells.has(key)) throw new Error(`acryl-state: key "${key}" is already in use`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private report = (error: unknown): void => {
|
|
38
|
+
this.ctx.logger.error(`acryl-state: a subscriber failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Create a writable cell owned by the calling plugin. The key must be new. */
|
|
42
|
+
cell<T>(key: string, initial: T): Cell<T> {
|
|
43
|
+
this.claim(key)
|
|
44
|
+
const cell = createCell(key, initial, this.report)
|
|
45
|
+
this.cells.set(key, cell as ReadonlyCell<unknown>)
|
|
46
|
+
this.ctx.effect(() => () => { this.cells.delete(key) }, `acryl-state: cell ${key}`)
|
|
47
|
+
return cell
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Create a read-only cell computed from other cells, owned by the calling plugin. */
|
|
51
|
+
derive<D extends readonly ReadonlyCell<unknown>[], T>(
|
|
52
|
+
key: string,
|
|
53
|
+
inputs: D,
|
|
54
|
+
compute: (...values: { [K in keyof D]: D[K] extends ReadonlyCell<infer V> ? V : never }) => T,
|
|
55
|
+
): ReadonlyCell<T> {
|
|
56
|
+
this.claim(key)
|
|
57
|
+
const derived = createDerived(key, inputs, compute, this.report)
|
|
58
|
+
this.cells.set(key, derived.cell as ReadonlyCell<unknown>)
|
|
59
|
+
this.ctx.effect(() => () => { derived.dispose(); this.cells.delete(key) }, `acryl-state: derived ${key}`)
|
|
60
|
+
return derived.cell
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A cell by key, if one is live. */
|
|
64
|
+
find<T = unknown>(key: string): ReadonlyCell<T> | undefined {
|
|
65
|
+
return this.cells.get(key) as ReadonlyCell<T> | undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
keys(): string[] {
|
|
69
|
+
return [...this.cells.keys()].sort()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The current value of `key` as a server-sent event stream: the value now, then one event per change, until the request is aborted, the client
|
|
74
|
+
* cancels, or the router is disposed. `streamSSE` holds the request in flight until then, so the router waits for the stream.
|
|
75
|
+
*/
|
|
76
|
+
stream(key: string, c: RouteContext): Response | undefined {
|
|
77
|
+
const cell = this.find(key)
|
|
78
|
+
if (cell === undefined) return undefined
|
|
79
|
+
return streamSSE(c, stream => {
|
|
80
|
+
stream.send(cell.get(), { event: 'value' })
|
|
81
|
+
return cell.subscribe(value => { stream.send(value, { event: 'value' }) })
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function apply(ctx: Context): void {
|
|
87
|
+
const state = new State(ctx)
|
|
88
|
+
// The same cells on the router, read-only, for any surface; present only where a router is. A request over a socket must come from this machine to this
|
|
89
|
+
// app; the terminal calls the router in process and is the host's own code.
|
|
90
|
+
ctx.inject(['router'], inner => {
|
|
91
|
+
const api = inner.router.group('/api/state', loopbackOnly({ inProcess: 'allow' }))
|
|
92
|
+
api.get('/', c => c.json({ keys: state.keys() }))
|
|
93
|
+
api.get('/:key', c => {
|
|
94
|
+
const cell = state.find(c.param('key') ?? '')
|
|
95
|
+
return cell === undefined ? c.json({ error: 'no such state' }, 404) : c.json({ key: cell.key, value: cell.get() })
|
|
96
|
+
})
|
|
97
|
+
api.get('/:key/events', c => state.stream(c.param('key') ?? '', c) ?? c.json({ error: 'no such state' }, 404))
|
|
98
|
+
// Over HTTP only where an adapter exists (Web and Desktop); the terminal needs none.
|
|
99
|
+
inner.inject(['routerHttp'], http => { http.routerHttp.mount('/api/state') })
|
|
100
|
+
})
|
|
101
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createCell, createDerived } from '../src/cell.ts'
|
|
3
|
+
|
|
4
|
+
describe('cells', () => {
|
|
5
|
+
it('holds a value, notifies on change with the previous value, and ignores a set to an equal value', () => {
|
|
6
|
+
const cell = createCell('a.b', 1)
|
|
7
|
+
const seen: Array<[number, number]> = []
|
|
8
|
+
cell.subscribe((value, previous) => seen.push([value, previous]))
|
|
9
|
+
cell.set(2)
|
|
10
|
+
cell.set(2)
|
|
11
|
+
cell.update(n => n + 1)
|
|
12
|
+
expect(cell.get()).toBe(3)
|
|
13
|
+
expect(seen).toEqual([[2, 1], [3, 2]])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('stops notifying after unsubscribe, and unsubscribing twice is harmless', () => {
|
|
17
|
+
const cell = createCell('a', 0)
|
|
18
|
+
const listener = vi.fn()
|
|
19
|
+
const stop = cell.subscribe(listener)
|
|
20
|
+
cell.set(1)
|
|
21
|
+
stop()
|
|
22
|
+
stop()
|
|
23
|
+
cell.set(2)
|
|
24
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('isolates a failing subscriber: the others still run and the error is reported', () => {
|
|
28
|
+
const report = vi.fn()
|
|
29
|
+
const cell = createCell('a', 0, report)
|
|
30
|
+
const after = vi.fn()
|
|
31
|
+
cell.subscribe(() => { throw new Error('boom') })
|
|
32
|
+
cell.subscribe(after)
|
|
33
|
+
cell.set(1)
|
|
34
|
+
expect(after).toHaveBeenCalledWith(1, 0)
|
|
35
|
+
expect(report).toHaveBeenCalledTimes(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('lets a subscriber unsubscribe itself while being notified', () => {
|
|
39
|
+
const cell = createCell('a', 0)
|
|
40
|
+
const calls: number[] = []
|
|
41
|
+
const stop = cell.subscribe(value => { calls.push(value); stop() })
|
|
42
|
+
cell.subscribe(value => calls.push(value * 10))
|
|
43
|
+
cell.set(1)
|
|
44
|
+
cell.set(2)
|
|
45
|
+
expect(calls).toEqual([1, 10, 20])
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('derived cells', () => {
|
|
50
|
+
it('recomputes from its inputs, notifies only when its own value changes, and detaches on dispose', () => {
|
|
51
|
+
const a = createCell('a', 2)
|
|
52
|
+
const b = createCell('b', 3)
|
|
53
|
+
const { cell: sum, dispose } = createDerived('sum', [a, b] as const, (x, y) => x + y)
|
|
54
|
+
const seen: number[] = []
|
|
55
|
+
sum.subscribe(value => seen.push(value))
|
|
56
|
+
expect(sum.get()).toBe(5)
|
|
57
|
+
a.set(10)
|
|
58
|
+
b.set(1)
|
|
59
|
+
expect(seen).toEqual([13, 11])
|
|
60
|
+
const parity = createDerived('parity', [a] as const, x => x % 2 === 0).cell
|
|
61
|
+
const flips: boolean[] = []
|
|
62
|
+
parity.subscribe(value => flips.push(value))
|
|
63
|
+
a.set(12)
|
|
64
|
+
a.set(14)
|
|
65
|
+
expect(flips).toEqual([])
|
|
66
|
+
dispose()
|
|
67
|
+
a.set(100)
|
|
68
|
+
// Detached: it keeps the last value it computed (14 + 1), not 100 + 1.
|
|
69
|
+
expect(sum.get()).toBe(15)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, render } from '@testing-library/react'
|
|
3
|
+
import { createElement } from 'react'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { createCell } from '../src/cell.ts'
|
|
6
|
+
import { useCell } from '../src/react.ts'
|
|
7
|
+
|
|
8
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
9
|
+
afterEach(cleanup)
|
|
10
|
+
|
|
11
|
+
function Counter({ cell }: { cell: ReturnType<typeof createCell<number>> }) {
|
|
12
|
+
return createElement('span', { 'data-testid': 'v' }, String(useCell(cell)))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('useCell', () => {
|
|
16
|
+
it('renders the current value and re-renders when the cell changes', () => {
|
|
17
|
+
const cell = createCell('r.count', 1)
|
|
18
|
+
const view = render(createElement(Counter, { cell }))
|
|
19
|
+
expect(view.getByTestId('v').textContent).toBe('1')
|
|
20
|
+
act(() => { cell.set(2) })
|
|
21
|
+
expect(view.getByTestId('v').textContent).toBe('2')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('unsubscribes on unmount, and a second component on the same cell stays in step', () => {
|
|
25
|
+
const cell = createCell('r.count', 0)
|
|
26
|
+
let live = 0
|
|
27
|
+
const watched = { ...cell, subscribe: (listener: Parameters<typeof cell.subscribe>[0]) => { live += 1; const stop = cell.subscribe(listener); return () => { live -= 1; stop() } } }
|
|
28
|
+
const a = render(createElement(Counter, { cell: watched }))
|
|
29
|
+
const b = render(createElement(Counter, { cell: watched }))
|
|
30
|
+
expect(live).toBe(2)
|
|
31
|
+
act(() => { cell.set(7) })
|
|
32
|
+
expect(a.container.querySelector('[data-testid=v]')?.textContent).toBe('7')
|
|
33
|
+
expect(b.container.querySelector('[data-testid=v]')?.textContent).toBe('7')
|
|
34
|
+
a.unmount()
|
|
35
|
+
expect(live).toBe(1)
|
|
36
|
+
b.unmount()
|
|
37
|
+
expect(live).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state service through a real Cordis `Context`: cells are owned by the plugin that made them, readable and
|
|
3
|
+
* subscribable in process, and streamed through the router as server-sent events, with cancellation and disposal.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
7
|
+
import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
|
+
import { apply, name } from '../src/index.ts'
|
|
10
|
+
|
|
11
|
+
const contexts: Context[] = []
|
|
12
|
+
const settle = (ms = 0): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
async function system(withRouter = true): Promise<Context> {
|
|
18
|
+
const ctx = new Context()
|
|
19
|
+
contexts.push(ctx)
|
|
20
|
+
if (withRouter) ctx.plugin({ name: routerName, apply: routerApply }, { trailingSlash: 'strict' })
|
|
21
|
+
ctx.plugin({ name, apply })
|
|
22
|
+
await settle()
|
|
23
|
+
return ctx
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A tiny SSE reader over a Response body. */
|
|
27
|
+
async function events(response: Response, count: number): Promise<string[]> {
|
|
28
|
+
const reader = response.body!.getReader()
|
|
29
|
+
const decoder = new TextDecoder()
|
|
30
|
+
let buffer = ''
|
|
31
|
+
const out: string[] = []
|
|
32
|
+
while (out.length < count) {
|
|
33
|
+
const { done, value } = await reader.read()
|
|
34
|
+
if (done) break
|
|
35
|
+
buffer += decoder.decode(value, { stream: true })
|
|
36
|
+
for (let at = buffer.indexOf('\n\n'); at !== -1; at = buffer.indexOf('\n\n')) {
|
|
37
|
+
out.push(buffer.slice(0, at))
|
|
38
|
+
buffer = buffer.slice(at + 2)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
reader.releaseLock()
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('acryl-state', () => {
|
|
46
|
+
it('creates cells owned by the calling plugin and removes them when it unloads', async () => {
|
|
47
|
+
const ctx = await system()
|
|
48
|
+
const fiber = ctx.plugin({ name: 'owner', inject: ['state'], apply(c: Context) { c.state.cell('owner.count', 1) } })
|
|
49
|
+
await settle()
|
|
50
|
+
expect(ctx.state.keys()).toEqual(['owner.count'])
|
|
51
|
+
expect(ctx.state.find<number>('owner.count')?.get()).toBe(1)
|
|
52
|
+
await fiber.dispose()
|
|
53
|
+
await settle()
|
|
54
|
+
expect(ctx.state.keys()).toEqual([])
|
|
55
|
+
expect(ctx.state.find('owner.count')).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('rejects a bad or duplicate key', async () => {
|
|
59
|
+
const ctx = await system()
|
|
60
|
+
ctx.state.cell('ok.key', 0)
|
|
61
|
+
expect(() => ctx.state.cell('ok.key', 1)).toThrow(/already in use/)
|
|
62
|
+
expect(() => ctx.state.cell('Bad Key', 1)).toThrow(/dotted lowercase/)
|
|
63
|
+
expect(() => ctx.state.derive('ok.key', [], () => 1)).toThrow(/already in use/)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('derives a cell from others and detaches it when its owner unloads', async () => {
|
|
67
|
+
const ctx = await system()
|
|
68
|
+
const a = ctx.state.cell<number>('n.a', 1)
|
|
69
|
+
const fiber = ctx.plugin({ name: 'deriver', inject: ['state'], apply(c: Context) { c.state.derive('n.double', [a] as const, x => x * 2) } })
|
|
70
|
+
await settle()
|
|
71
|
+
expect(ctx.state.find<number>('n.double')?.get()).toBe(2)
|
|
72
|
+
a.set(5)
|
|
73
|
+
expect(ctx.state.find<number>('n.double')?.get()).toBe(10)
|
|
74
|
+
await fiber.dispose()
|
|
75
|
+
await settle()
|
|
76
|
+
expect(ctx.state.find('n.double')).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('serves keys and values as JSON on the router, read-only', async () => {
|
|
80
|
+
const ctx = await system()
|
|
81
|
+
ctx.state.cell('app.title', 'hello')
|
|
82
|
+
const keys = await ctx.router.fetch(new Request('http://acryl.test/api/state'))
|
|
83
|
+
expect(await keys.json()).toEqual({ keys: ['app.title'] })
|
|
84
|
+
const one = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.title'))
|
|
85
|
+
expect(await one.json()).toEqual({ key: 'app.title', value: 'hello' })
|
|
86
|
+
expect((await ctx.router.fetch(new Request('http://acryl.test/api/state/none'))).status).toBe(404)
|
|
87
|
+
expect((await ctx.router.fetch(new Request('http://acryl.test/api/state/app.title', { method: 'PUT', body: '"x"' }))).status).toBe(405)
|
|
88
|
+
expect(ctx.state.find('app.title')?.get()).toBe('hello')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('streams the value now and every change as server-sent events, and ends when the caller aborts', async () => {
|
|
92
|
+
const ctx = await system()
|
|
93
|
+
const cell = ctx.state.cell<number>('app.count', 0)
|
|
94
|
+
const controller = new AbortController()
|
|
95
|
+
const response = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.count/events', { signal: controller.signal }))
|
|
96
|
+
expect(response.headers.get('content-type')).toContain('text/event-stream')
|
|
97
|
+
const reading = events(response, 3)
|
|
98
|
+
await settle(5)
|
|
99
|
+
cell.set(1)
|
|
100
|
+
cell.set(2)
|
|
101
|
+
expect(await reading).toEqual(['event: value\ndata: 0', 'event: value\ndata: 1', 'event: value\ndata: 2'])
|
|
102
|
+
controller.abort()
|
|
103
|
+
await settle(5)
|
|
104
|
+
// After the abort the stream no longer holds a subscription on the cell.
|
|
105
|
+
let notified = 0
|
|
106
|
+
const probe = ctx.state.find<number>('app.count')!.subscribe(() => { notified += 1 })
|
|
107
|
+
cell.set(3)
|
|
108
|
+
probe()
|
|
109
|
+
expect(notified).toBe(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('closes every open stream when the router is disposed', async () => {
|
|
113
|
+
const ctx = await system()
|
|
114
|
+
ctx.state.cell<number>('app.count', 0)
|
|
115
|
+
const routerFiber = [...[...ctx.registry.values()].find(runtime => runtime.name === routerName)!.fibers][0]!
|
|
116
|
+
const response = await ctx.router.fetch(new Request('http://acryl.test/api/state/app.count/events'))
|
|
117
|
+
const reader = response.body!.getReader()
|
|
118
|
+
await reader.read()
|
|
119
|
+
const ended = reader.read()
|
|
120
|
+
await routerFiber.dispose()
|
|
121
|
+
await expect(ended).resolves.toMatchObject({ done: true })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('works with no router: cells and subscriptions only, no routes', async () => {
|
|
125
|
+
const ctx = await system(false)
|
|
126
|
+
const cell = ctx.state.cell<string>('plain.value', 'a')
|
|
127
|
+
const seen: string[] = []
|
|
128
|
+
cell.subscribe(value => seen.push(value))
|
|
129
|
+
cell.set('b')
|
|
130
|
+
expect(seen).toEqual(['b'])
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"repo": "acryldev/acryl",
|
|
4
|
+
"path": "plugins/acryl-state",
|
|
5
|
+
"commit": "11fed295161c0282d144b5ac62616988b58d8189"
|
|
6
|
+
},
|
|
7
|
+
"vendored": [
|
|
8
|
+
"src",
|
|
9
|
+
"tests",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"rewrittenSpecifiers": 4,
|
|
13
|
+
"digest": "sha256:528bdc35dbbe22dfae8822fe054cfbfccae9554b9e06089dd7e269f44e27add4",
|
|
14
|
+
"testsNotRun": {
|
|
15
|
+
"tests/react.spec.ts": "needs react and @testing-library/react, which generated apps do not install"
|
|
16
|
+
},
|
|
17
|
+
"notice": [
|
|
18
|
+
"LICENSE"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type {} from './memory.ts'
|
|
3
|
+
import type {} from './model.ts'
|
|
4
|
+
import type {} from './tools.ts'
|
|
5
|
+
|
|
6
|
+
/** One tool call made during a run. */
|
|
7
|
+
export interface ToolCall {
|
|
8
|
+
readonly tool: string
|
|
9
|
+
readonly input: unknown
|
|
10
|
+
readonly result?: unknown
|
|
11
|
+
readonly error?: string
|
|
12
|
+
/** The thinking model's reasoning for this call, kept so the next step can send it back. */
|
|
13
|
+
readonly reasoning?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** How a run ended, with every tool call it made. `task` is its durable record's id when the app records tasks. */
|
|
17
|
+
export type RunResult = (
|
|
18
|
+
| { readonly status: 'completed'; readonly output: string }
|
|
19
|
+
| { readonly status: 'budget_exceeded' }
|
|
20
|
+
| { readonly status: 'cancelled' }
|
|
21
|
+
) & { readonly steps: number; readonly trace: readonly ToolCall[]; readonly task?: string }
|
|
22
|
+
|
|
23
|
+
export interface Agent {
|
|
24
|
+
run(question: string): Promise<RunResult>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The step budget of one run, provided inside the run's own fiber. */
|
|
28
|
+
export interface Budget {
|
|
29
|
+
steps: number
|
|
30
|
+
readonly limit: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AgentConfig {
|
|
34
|
+
/** Steps per run before it stops. */
|
|
35
|
+
steps?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The part of a task recorder the agent uses. Optional: when an app mounts the `tasks` plugin, each run is a durable task and each
|
|
40
|
+
* tool call a task it owns; without it, runs work the same and leave no record.
|
|
41
|
+
*/
|
|
42
|
+
interface TaskRecorder {
|
|
43
|
+
start(
|
|
44
|
+
kind: string,
|
|
45
|
+
input: unknown,
|
|
46
|
+
options?: { parent?: string },
|
|
47
|
+
): {
|
|
48
|
+
readonly id: string
|
|
49
|
+
readonly signal: AbortSignal
|
|
50
|
+
progress(detail: unknown): void
|
|
51
|
+
complete(result: unknown, detail?: unknown): void
|
|
52
|
+
fail(error: unknown, detail?: unknown): void
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
declare module '@deepseek-ai/cordis' {
|
|
57
|
+
interface Context {
|
|
58
|
+
agent: Agent
|
|
59
|
+
budget: Budget
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 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
|
+
* 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.
|
|
68
|
+
* When the app records tasks, the run's record is written before the answer is returned, and cancelling it stops the run.
|
|
69
|
+
*/
|
|
70
|
+
export const agent = {
|
|
71
|
+
name: 'agent',
|
|
72
|
+
inject: ['model', 'tools', 'memory'],
|
|
73
|
+
apply(ctx: Context, config?: AgentConfig) {
|
|
74
|
+
const limit = config?.steps ?? 6
|
|
75
|
+
let runs = 0
|
|
76
|
+
ctx.provide('agent', {
|
|
77
|
+
async run(question) {
|
|
78
|
+
runs += 1
|
|
79
|
+
const trace: ToolCall[] = []
|
|
80
|
+
const recorder = (ctx.get as (name: string) => unknown)('tasks') as TaskRecorder | undefined
|
|
81
|
+
const record = recorder?.start('agent-run', { question })
|
|
82
|
+
// Assigned inside the run's fiber; `as` keeps TypeScript from narrowing it to this first value.
|
|
83
|
+
let result = { status: 'budget_exceeded', steps: 0, trace } as RunResult
|
|
84
|
+
const task = ctx.plugin({
|
|
85
|
+
name: `task-${runs}`,
|
|
86
|
+
inject: ['model', 'tools', 'memory'],
|
|
87
|
+
async apply(scope: Context) {
|
|
88
|
+
scope.provide('budget', { steps: 0, limit } satisfies Budget)
|
|
89
|
+
const budget = scope.budget
|
|
90
|
+
let toolResult: unknown
|
|
91
|
+
while (budget.steps < budget.limit) {
|
|
92
|
+
if (record?.signal.aborted) {
|
|
93
|
+
result = { status: 'cancelled', steps: budget.steps, trace }
|
|
94
|
+
return
|
|
95
|
+
}
|
|
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
|
+
budget.steps += 1
|
|
104
|
+
if (step.tool === undefined) {
|
|
105
|
+
scope.memory.remember(`${question} => ${step.text}`)
|
|
106
|
+
result = { status: 'completed', output: step.text, steps: budget.steps, trace }
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const call = record
|
|
110
|
+
? recorder?.start(`tool:${step.tool}`, step.input, { parent: record.id })
|
|
111
|
+
: undefined
|
|
112
|
+
try {
|
|
113
|
+
toolResult = await scope.tools.call(step.tool, step.input)
|
|
114
|
+
trace.push({
|
|
115
|
+
tool: step.tool,
|
|
116
|
+
input: step.input,
|
|
117
|
+
result: toolResult,
|
|
118
|
+
...(step.reasoning !== undefined ? { reasoning: step.reasoning } : {}),
|
|
119
|
+
})
|
|
120
|
+
call?.complete(toolResult)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
toolResult = { error: message(error) }
|
|
123
|
+
trace.push({
|
|
124
|
+
tool: step.tool,
|
|
125
|
+
input: step.input,
|
|
126
|
+
error: message(error),
|
|
127
|
+
...(step.reasoning !== undefined ? { reasoning: step.reasoning } : {}),
|
|
128
|
+
})
|
|
129
|
+
call?.fail(error)
|
|
130
|
+
}
|
|
131
|
+
record?.progress({ steps: budget.steps, trace })
|
|
132
|
+
}
|
|
133
|
+
result = { status: 'budget_exceeded', steps: budget.steps, trace }
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
try {
|
|
137
|
+
await task.await()
|
|
138
|
+
} catch (error) {
|
|
139
|
+
record?.fail(error, { trace })
|
|
140
|
+
throw error
|
|
141
|
+
} finally {
|
|
142
|
+
await task.dispose()
|
|
143
|
+
}
|
|
144
|
+
if (result.status === 'completed')
|
|
145
|
+
record?.complete(result.output, { steps: result.steps, trace })
|
|
146
|
+
else if (result.status === 'budget_exceeded')
|
|
147
|
+
record?.fail('the run used its whole step budget', { steps: result.steps, trace })
|
|
148
|
+
return record ? { ...result, task: record.id } : result
|
|
149
|
+
},
|
|
150
|
+
} satisfies Agent)
|
|
151
|
+
},
|
|
152
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The offline agent: a scripted model, a tool registry, in-process memory, and one child fiber per run. */
|
|
2
|
+
export {
|
|
3
|
+
type Agent,
|
|
4
|
+
type AgentConfig,
|
|
5
|
+
agent,
|
|
6
|
+
type Budget,
|
|
7
|
+
type RunResult,
|
|
8
|
+
type ToolCall,
|
|
9
|
+
} from './agent.ts'
|
|
10
|
+
export { type Memory, memory } from './memory.ts'
|
|
11
|
+
export {
|
|
12
|
+
type Model,
|
|
13
|
+
type ModelInput,
|
|
14
|
+
model,
|
|
15
|
+
type Rule,
|
|
16
|
+
type ScriptedModel,
|
|
17
|
+
type Step,
|
|
18
|
+
scripted,
|
|
19
|
+
} from './model.ts'
|
|
20
|
+
export { type Tool, type Tools, tools } from './tools.ts'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
|
|
3
|
+
/** Long-term memory for the agent. */
|
|
4
|
+
export interface Memory {
|
|
5
|
+
remember(text: string): void
|
|
6
|
+
recall(): string[]
|
|
7
|
+
size(): number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare module '@deepseek-ai/cordis' {
|
|
11
|
+
interface Context {
|
|
12
|
+
memory: Memory
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** In-process memory. Replace it with a plugin backed by storage when you need persistence. */
|
|
17
|
+
export function memory(ctx: Context) {
|
|
18
|
+
const items: string[] = []
|
|
19
|
+
ctx.provide('memory', {
|
|
20
|
+
remember: (text) => void items.push(text),
|
|
21
|
+
recall: () => [...items],
|
|
22
|
+
size: () => items.length,
|
|
23
|
+
} satisfies Memory)
|
|
24
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
|
|
3
|
+
/** One model step: final text, or a tool to call with its input. `reasoning` is a thinking model's reasoning for the call; it must be given back on the next step, so the run keeps it. */
|
|
4
|
+
export type Step =
|
|
5
|
+
| { readonly text: string; readonly tool?: undefined }
|
|
6
|
+
| { readonly tool: string; readonly input: unknown; readonly reasoning?: string }
|
|
7
|
+
|
|
8
|
+
/** What a model sees at each step of a run. */
|
|
9
|
+
export interface ModelInput {
|
|
10
|
+
readonly question: string
|
|
11
|
+
/** 0 for the first step of a run. */
|
|
12
|
+
readonly step: number
|
|
13
|
+
/** The previous tool's result, if the previous step called one. */
|
|
14
|
+
readonly toolResult?: unknown
|
|
15
|
+
/** Every tool call of this run so far, in order: a real model rebuilds the conversation from it. */
|
|
16
|
+
readonly trace?: ReadonlyArray<{
|
|
17
|
+
readonly tool: string
|
|
18
|
+
readonly input: unknown
|
|
19
|
+
readonly result?: unknown
|
|
20
|
+
readonly error?: string
|
|
21
|
+
readonly reasoning?: string
|
|
22
|
+
}>
|
|
23
|
+
/** The tools the model may call. */
|
|
24
|
+
readonly tools?: ReadonlyArray<{ readonly name: string; readonly description: string }>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the agent needs from a model. Replace this plugin with a real provider when you have one. */
|
|
28
|
+
export interface Model {
|
|
29
|
+
complete(input: ModelInput): Promise<Step>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A rule of the scripted model: a step for the input, or `undefined` when it does not apply. */
|
|
33
|
+
export type Rule = (input: ModelInput) => Step | undefined
|
|
34
|
+
|
|
35
|
+
/** The deterministic model. Other plugins teach it rules for their commands, so whole flows are tested without a network. */
|
|
36
|
+
export interface ScriptedModel extends Model {
|
|
37
|
+
/** Add a rule, tried before the built-in ones. Returns the function that removes it. */
|
|
38
|
+
teach(rule: Rule): () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare module '@deepseek-ai/cordis' {
|
|
42
|
+
interface Context {
|
|
43
|
+
model: Model
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const builtIn: Rule[] = [
|
|
48
|
+
({ toolResult, step }) =>
|
|
49
|
+
step > 0 ? { text: `The answer is ${String(toolResult)}.` } : undefined,
|
|
50
|
+
({ question }) => {
|
|
51
|
+
const sum = /^add\s+(-?\d+)\s+(-?\d+)$/.exec(question.trim())
|
|
52
|
+
return sum ? { tool: 'add', input: [Number(sum[1]), Number(sum[2])] } : undefined
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/** A deterministic model: no keys, no network, the same answer every time. */
|
|
57
|
+
export function model(ctx: Context) {
|
|
58
|
+
const taught: Rule[] = []
|
|
59
|
+
ctx.provide('model', {
|
|
60
|
+
teach(rule) {
|
|
61
|
+
taught.push(rule)
|
|
62
|
+
return () => void taught.splice(taught.indexOf(rule), 1)
|
|
63
|
+
},
|
|
64
|
+
complete(input) {
|
|
65
|
+
for (const rule of [...taught, ...builtIn]) {
|
|
66
|
+
const step = rule(input)
|
|
67
|
+
if (step) return Promise.resolve(step)
|
|
68
|
+
}
|
|
69
|
+
return Promise.resolve({ text: `You said: ${input.question}` })
|
|
70
|
+
},
|
|
71
|
+
} satisfies ScriptedModel)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The scripted model, when the app runs one (a real provider has no `teach`). */
|
|
75
|
+
export function scripted(model: Model | undefined): ScriptedModel | undefined {
|
|
76
|
+
return model && typeof (model as Partial<ScriptedModel>).teach === 'function'
|
|
77
|
+
? (model as ScriptedModel)
|
|
78
|
+
: undefined
|
|
79
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent",
|
|
3
|
+
"description": "An offline agent: deterministic model, tools, memory, and a child fiber per run that holds the run's step budget.",
|
|
4
|
+
"requires": [],
|
|
5
|
+
"inject": ["model", "tools", "memory"],
|
|
6
|
+
"provides": ["agent", "model", "tools", "memory"],
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4"
|
|
9
|
+
}
|
|
10
|
+
}
|