@fugood/buttress-server 2.25.5-beta.1 → 2.25.6
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 +164 -4
- package/config/function-samples/README.md +2 -0
- package/config/function-samples/bank-note.ts +47 -0
- package/config/function-samples/bank-watch-daemon.ts +63 -0
- package/config/function-samples/run-agent.ts +39 -0
- package/config/sample.toml +22 -0
- package/lib/agent/cli.d.ts +19 -0
- package/lib/agent/client.d.ts +66 -0
- package/lib/agent/config.d.ts +15 -0
- package/lib/agent/context.d.ts +11 -0
- package/lib/agent/loopback.d.ts +21 -0
- package/lib/agent/mcp.d.ts +23 -0
- package/lib/agent/models.d.ts +20 -0
- package/lib/agent/service.d.ts +16 -0
- package/lib/agent/session-fs.d.ts +42 -0
- package/lib/agent/sessions.d.ts +15 -0
- package/lib/agent/tools.d.ts +32 -0
- package/lib/agent/tui.d.ts +17 -0
- package/lib/agent/types.d.ts +123 -0
- package/lib/cli-DrbWX4ea.mjs +22 -0
- package/lib/client-BCBBen9i.mjs +8 -0
- package/lib/config-lP89VahD.mjs +2 -0
- package/lib/functions/bank-subscribe.d.ts +46 -0
- package/lib/functions/bank.d.ts +21 -0
- package/lib/functions/daemons.d.ts +45 -0
- package/lib/functions/executor.d.ts +31 -4
- package/lib/functions/index.d.ts +17 -7
- package/lib/functions/registry.d.ts +7 -1
- package/lib/functions/status.d.ts +49 -1
- package/lib/functions/templates.d.ts +3 -1
- package/lib/functions/types.d.ts +129 -0
- package/lib/index.d.ts +8 -2
- package/lib/index.mjs +263 -48
- package/lib/mlx-bridge.py +681 -0
- package/lib/routes/agents.d.ts +37 -0
- package/lib/routes/anthropic-messages.d.ts +2 -2
- package/lib/routes/index.d.ts +1 -0
- package/lib/routes/openai-compat.d.ts +2 -2
- package/lib/tui-7B7x6A08.mjs +2 -0
- package/lib/types.d.ts +9 -0
- package/lib/utils/cors.check.d.ts +1 -0
- package/lib/utils/cors.d.ts +72 -0
- package/lib/utils/workspaceState.d.ts +9 -0
- package/package.json +9 -6
- package/public/status.html +77 -1
- package/public/lib/index.d.ts +0 -27
- package/public/lib/index.mjs +0 -110
package/README.md
CHANGED
|
@@ -154,6 +154,7 @@ Configuration is loaded from a TOML file passed via `--config` / `-c`. Every top
|
|
|
154
154
|
| `[openai_compat]` | Enable `/oai-compat/v1/*` — see [Compatibility Endpoints](#compatibility-endpoints-experimental) |
|
|
155
155
|
| `[anthropic_messages]` | Enable `/anthropic-messages` — see [Compatibility Endpoints](#compatibility-endpoints-experimental)|
|
|
156
156
|
| `[functions]` | Enable local functions — see [Local Functions](#local-functions-experimental) |
|
|
157
|
+
| `[[agents]]` | Config-defined agents — see [Agents](#agents-experimental) |
|
|
157
158
|
| `[[generators]]` | Array of generator instances — one entry per loaded model |
|
|
158
159
|
|
|
159
160
|
### `[env]`
|
|
@@ -653,9 +654,9 @@ dir = "./functions" # relative paths resolve against this config file
|
|
|
653
654
|
|
|
654
655
|
`ENABLE_FUNCTIONS_ENDPOINT=1` and `BUTTRESS_FUNCTIONS_DIR=<dir>` are equivalent to the first two keys.
|
|
655
656
|
|
|
656
|
-
On startup the server creates the directory if needed and writes `buttress-functions.d.ts` (ambient types, refreshed every start), plus a `tsconfig.json` and
|
|
657
|
+
On startup the server creates the directory if needed and writes `buttress-functions.d.ts` (ambient types, refreshed every start), plus a `tsconfig.json` and commented `_example.ts` / `_example-daemon.ts` files when the directory holds no functions yet.
|
|
657
658
|
|
|
658
|
-
Ready-to-copy examples — a no-prerequisite starter, LLM summarization, sqlite-vec RAG, ffmpeg + STT transcription, TTS with a downloadable result, and a custom auth gate — live in [`config/function-samples/`](config/function-samples/).
|
|
659
|
+
Ready-to-copy examples — a no-prerequisite starter, LLM summarization, sqlite-vec RAG, ffmpeg + STT transcription, TTS with a downloadable result, a remote Data Bank read/write, a Data Bank–watching daemon, and a custom auth gate — live in [`config/function-samples/`](config/function-samples/).
|
|
659
660
|
|
|
660
661
|
### Writing a function
|
|
661
662
|
|
|
@@ -693,6 +694,8 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
|
|
|
693
694
|
| `buttress.detokenize({ model?, tokens })` | Convert token ids back into text with the same model. |
|
|
694
695
|
| `buttress.transcribe({ model?, filePath \| audioData, options? })` | Transcribe audio with this server's STT generator. |
|
|
695
696
|
| `buttress.synthesize({ model?, text, options? })` | Synthesize speech with this server's TTS generator (`onnx-tts` or `ggml-tts`); the WAV lands in `tempDir` → `{ path, sampling_rate, channels }`. |
|
|
697
|
+
| `bank.list({ keyword?, meta?, ids? })` / `bank.get(id)` / `bank.update(properties, { dontNotify? })` / `bank.remove(id)` | Read/write the bound workspace's remote **Data Bank** (see below). To listen for changes, use `bank.subscribe` inside a daemon function. |
|
|
698
|
+
| `daemons.emit(name, event, data?)` / `daemons.list()` | Signal a running **daemon function**'s `onEvent` handlers (see below) / list running daemon names. |
|
|
696
699
|
| `emit(event, data)` | Progress event; delivered to SSE callers, ignored otherwise. |
|
|
697
700
|
| `signal` | `AbortSignal`, aborted on timeout or caller disconnect. |
|
|
698
701
|
| `tempDir` | Per-call scratch directory, created on first access. |
|
|
@@ -700,6 +703,52 @@ export default async function ({ path }: { path: string }, context: ButtressFunc
|
|
|
700
703
|
| `log`, `fetch`, `env`, `config`, `dir` | Prefixed logging, host `fetch`, `process.env`, the `[functions.config]` table, the functions directory. |
|
|
701
704
|
| `libs` | `_`/`lodash`, `moment`, `math`/`mathjs`, `voca`, `chroma`, `json5`, `qs`, `bytes`, `ms`, `nanoid`, `md5`. |
|
|
702
705
|
|
|
706
|
+
#### Remote Data Bank (`context.bank`)
|
|
707
|
+
|
|
708
|
+
A bound server can read and write the workspace's remote **Data Bank** — the same property space the `bricks data` CLI commands and remote-update apps use. Credentials are issued through the Workspace API by the CLI and stored next to the workspace binding:
|
|
709
|
+
|
|
710
|
+
```bash
|
|
711
|
+
bricks buttress bank-key # on the server host, from a workspace-authed CLI
|
|
712
|
+
# then restart buttress-server
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
This writes a `bank` entry (Data Bank endpoint + spacename + space key) into `~/.bricks-cli/buttress/state.json`. Until it exists, every `context.bank` method throws with a hint. `bricks buttress bank-key --revoke` revokes the key and removes the entry; re-running `bank-key` rotates it.
|
|
716
|
+
|
|
717
|
+
`context.bank` is read/write; to *react* to Data Bank changes, use `bank.subscribe` inside a daemon function (below) — it throws elsewhere. Mind one Bank semantic: `update` **replaces `value` unconditionally** (an omitted `value` clears the stored one), so read-merge first when only part of an object should change. Updates notify subscribed devices unless you pass `{ dontNotify: true }`. See `config/function-samples/bank-note.ts` for the end-to-end shape.
|
|
718
|
+
|
|
719
|
+
#### Daemon functions (`meta.daemon`)
|
|
720
|
+
|
|
721
|
+
A file with `meta.daemon = true` runs as a **daemon**: a long-lived background function. Its default export runs **once** when the daemon starts — with a `ButtressDaemonContext` as its only argument — and everything it registers there keeps running after it returns. Daemons are not MCP tools and cannot be called over HTTP (a direct call returns `400 FUNCTION_NOT_CALLABLE`); `GET /functions` lists them in a separate `daemons` array. `meta.timeout` does not apply — a daemon has no deadline.
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
export const meta: ButtressFunctionMeta = {
|
|
725
|
+
description: 'Watch the Data Bank and keep a summary fresh',
|
|
726
|
+
daemon: true,
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export default async function (context: ButtressDaemonContext) {
|
|
730
|
+
context.setInterval(async () => {
|
|
731
|
+
// periodic work; an error here is logged, the timer keeps firing
|
|
732
|
+
}, 5 * 60 * 1000)
|
|
733
|
+
|
|
734
|
+
context.bank.subscribe(['orders', 'menu'], async (properties) => {
|
|
735
|
+
// remote Data Bank changes to the watched property ids
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
context.onEvent(async ({ event, data, source }) => {
|
|
739
|
+
// another local function called context.daemons.emit('<this-file>', event, data)
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
return () => {
|
|
743
|
+
// optional cleanup, runs when the daemon stops
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
The daemon context is the full function context plus lifetime APIs. `setInterval(cb, ms)` / `clearInterval(handle)` are managed timers: a callback that throws is logged (and recorded on `/status`) but never stops the timer or the daemon; everything is cleared when the daemon stops. `bank.subscribe(propertyIds, onChange)` uses the same Bank credentials as `context.bank` (`bricks buttress bank-key`; throws until stored); all subscriptions share one auto-reconnecting connection, so a network drop never stops the daemon — note a daemon that writes the properties it watches also notifies itself unless it passes `{ dontNotify: true }`. `onEvent(handler)` receives what other local functions (and daemons) send with `context.daemons.emit(name, event, data?)`, as `{ event, data, source }`; `emit` throws when the target daemon is not running or registered no handler, and events emitted while a daemon is still starting are buffered until it settles.
|
|
749
|
+
|
|
750
|
+
The handler runs with no deadline; `context.signal` aborts when the daemon stops. A file edit restarts it (cleanup → fresh module → start) — on save with `hot_reload = true`, otherwise on the periodic (~60s) reconcile sweep. A start invocation that throws, or a file that stops loading, suspends the daemon (shown as an error on `/status`) until the file changes again. On stop, timers and subscriptions are torn down, spawned children are killed, and the returned cleanup gets a bounded (~10s) run. `context.emit` (SSE progress) is a no-op inside a daemon — there is no caller; use `context.log` and the status page.
|
|
751
|
+
|
|
703
752
|
Functions may `import` Node builtins (`node:fs/promises`, …), sibling files inside the functions directory, and the two provided database packages `sqlite3` and `sqlite-vec`. Other package imports are rejected. `sqlite-vec` supports macOS and Linux on x64/arm64, plus Windows x64; its upstream package does not ship a Windows arm64 extension, so the standalone Windows arm64 build does not provide either SQLite import.
|
|
704
753
|
|
|
705
754
|
Edits are picked up on the next call — the server re-transpiles when a file in the function's module graph changes, so no restart is needed. A file that fails to load is logged and skipped; the rest keep working.
|
|
@@ -718,7 +767,7 @@ For faster authoring feedback, opt into eager reloading with `[functions] hot_re
|
|
|
718
767
|
| `GET /functions/files/<path>` | Download a file a function wrote to its `tempDir` — functions hand out these URLs via `context.fileUrl` |
|
|
719
768
|
| `POST /functions/upload` | Stage an input file on the server (multipart, `file` field) → `{ "path", "url", "name", "size" }` |
|
|
720
769
|
|
|
721
|
-
Errors come back as `{ "error": { "code", "message" } }` with `FUNCTION_NOT_FOUND` (404), `FUNCTION_TIMEOUT` (504), `FUNCTION_FAILED` (500) or `FUNCTION_FILE_NOT_FOUND` (404).
|
|
770
|
+
Errors come back as `{ "error": { "code", "message" } }` with `FUNCTION_NOT_FOUND` (404), `FUNCTION_NOT_CALLABLE` (400, the name is a daemon), `FUNCTION_TIMEOUT` (504), `FUNCTION_FAILED` (500) or `FUNCTION_FILE_NOT_FOUND` (404).
|
|
722
771
|
|
|
723
772
|
#### Calling with GET
|
|
724
773
|
|
|
@@ -752,7 +801,7 @@ curl -X POST <base>/functions/transcribe-media -F file=@interview.mp4
|
|
|
752
801
|
|
|
753
802
|
To stage a file once and reuse it across calls, `curl -F file=@interview.mp4 <base>/functions/upload` stores it in its own scratch directory and returns `{ "path", "url", "name", "size" }` — pass `path` as the function's input. Either way the client file name is sanitized to a bare name, and staged files share the auth guard and the ~24h sweep. Requests are capped by `[server] max_body_size` (default 50MB) — raise it for large media.
|
|
754
803
|
|
|
755
|
-
All of this activity is observable: the `/status` dashboard (and the `/buttress/status` JSON it polls) carries a **Local Functions** card with counters since startup and recent history for calls (per surface: HTTP/SSE/MCP, with durations and failure reasons), uploads, downloads, and auth decisions (allowed/denied with mode and subject — never credentials).
|
|
804
|
+
All of this activity is observable: the `/status` dashboard (and the `/buttress/status` JSON it polls) carries a **Local Functions** card with counters since startup and recent history for calls (per surface: HTTP/SSE/MCP, with durations and failure reasons), daemon callback runs, uploads, downloads, and auth decisions (allowed/denied with mode and subject — never credentials). Daemons additionally get a live table: running/error state, start time, active timers, Bank subscription health, listening flag, and callback run/failure counts.
|
|
756
805
|
|
|
757
806
|
To point an agent at the MCP endpoint:
|
|
758
807
|
|
|
@@ -802,6 +851,117 @@ The handler receives `{ method, path, name?, headers, query, token, workspaceAut
|
|
|
802
851
|
|
|
803
852
|
Function files themselves are trusted input, exactly like this config file. They run in a `node:vm` context with a clean global (no ambient `process` or `require`), but that is for clarity, not isolation — a function that is handed `spawn` can do anything the server process can. Only put code you wrote (or reviewed) in the functions directory.
|
|
804
853
|
|
|
854
|
+
## Agents (Experimental)
|
|
855
|
+
|
|
856
|
+
Buttress can host **agents**: pi-based LLM loops that run inside the server
|
|
857
|
+
process, use **local functions** (and MCP servers) as their tools, and keep
|
|
858
|
+
**config-scoped sessions** on disk. The primary consumer is automation — a
|
|
859
|
+
local function or daemon calls `context.agents.run(...)` to add multi-step
|
|
860
|
+
reasoning to a server-side workflow; an interactive CLI exists for driving and
|
|
861
|
+
inspecting the same agents.
|
|
862
|
+
|
|
863
|
+
```toml
|
|
864
|
+
[[agents]]
|
|
865
|
+
name = "ops-assistant" # unique; the session scope key
|
|
866
|
+
model = "buttress/ggml-org/gpt-oss-20b-GGUF" # split on the FIRST slash: provider/model-id
|
|
867
|
+
# model = "anthropic/claude-sonnet-5" # any pi-supported provider; API key from env
|
|
868
|
+
system_prompt_file = "./prompts/ops.md" # or inline: system_prompt = "..."
|
|
869
|
+
tools = ["get_server_status", "restart_service"] # local function names (explicit; no wildcard)
|
|
870
|
+
max_turns = 30 # assistant↔tool round-trips per run (default 30)
|
|
871
|
+
# max_tokens_per_run = 200000 # per-run token budget; unset = unlimited
|
|
872
|
+
# temperature = 0.2 # unrecognized keys pass through to generation
|
|
873
|
+
|
|
874
|
+
[agents.mcp_servers.github] # optional MCP servers (StreamableHTTP or stdio)
|
|
875
|
+
url = "https://api.githubcopilot.com/mcp/"
|
|
876
|
+
# headers = { Authorization = "Bearer ..." }
|
|
877
|
+
# optional = true # continue without this server if it won't connect
|
|
878
|
+
|
|
879
|
+
[agents_options] # optional; defaults shown
|
|
880
|
+
# sessions_dir = "./.buttress-agent/sessions" # relative to this config file
|
|
881
|
+
# session_max_age = "30d" # retention sweep; 0 disables
|
|
882
|
+
# session_max_count = 500 # per agent; 0 disables
|
|
883
|
+
# max_depth = 2 # function → agent → function → agent chain cap
|
|
884
|
+
# allow_unauthenticated = false # serve /agents on an UNBOUND server (see below)
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
**Models.** `buttress/<repo_id>` targets a configured `[[generators]]` LLM
|
|
888
|
+
(ggml/mlx) and is validated at startup; traffic flows through an in-process
|
|
889
|
+
OpenAI-compat loopback (no socket, no extra config — `[openai_compat] enabled`
|
|
890
|
+
still only governs the external HTTP route). Any other provider prefix is a pi
|
|
891
|
+
built-in provider (`anthropic/…`, `openai/…`, `google/…`, …) authenticated by
|
|
892
|
+
its usual environment variable (`ANTHROPIC_API_KEY`, …) — set it via `[env]`
|
|
893
|
+
or the process environment. OAuth-based logins are not supported headless.
|
|
894
|
+
|
|
895
|
+
**Tools.** `tools` lists local function names explicitly. Each tool call runs
|
|
896
|
+
through the normal functions executor (same lazy reload, scratch dir, spawn
|
|
897
|
+
tracking, and the function's own `meta.timeout` deadline), and aborting the
|
|
898
|
+
run aborts in-flight tool calls and their spawned processes. A listed function
|
|
899
|
+
that is missing fails the run loudly rather than letting a headless automation
|
|
900
|
+
improvise around it. MCP tools get server-qualified names
|
|
901
|
+
(`mcp__github__create_issue`); MCP servers connect lazily on the first run and
|
|
902
|
+
fail closed unless marked `optional = true`.
|
|
903
|
+
|
|
904
|
+
**Sessions.** Each run returns a `sessionId`; pass it back to continue the
|
|
905
|
+
conversation, or add `fork: true` to branch it into a fresh session. Sessions
|
|
906
|
+
are JSONL files under `sessions_dir`, scoped by agent name (renaming an agent
|
|
907
|
+
orphans its sessions), written as the run streams — an aborted or timed-out
|
|
908
|
+
run still leaves a continuable transcript. Same-session runs queue; different
|
|
909
|
+
sessions run in parallel. A retention sweep prunes by age and count.
|
|
910
|
+
|
|
911
|
+
**From a local function** (the primary surface):
|
|
912
|
+
|
|
913
|
+
```ts
|
|
914
|
+
export default async ({ service }, context) => {
|
|
915
|
+
const result = await context.agents.run('ops-assistant', {
|
|
916
|
+
prompt: `Investigate the '${service}' service and fix it if needed.`,
|
|
917
|
+
// sessionId, fork, onEvent are also accepted
|
|
918
|
+
})
|
|
919
|
+
return { conclusion: result.content, sessionId: result.sessionId }
|
|
920
|
+
}
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
`context.agents.run` resolves within the caller's lifetime and deadline
|
|
924
|
+
(`context.signal` aborts it); long-running agent work belongs in a **daemon**
|
|
925
|
+
(no deadline) or a function with a raised `meta.timeout`. `context.agents.list()`
|
|
926
|
+
and `context.agents.sessions(name)` round out the surface. Chained invocations
|
|
927
|
+
(a tool function calling another agent) are capped by `agents_options.max_depth`.
|
|
928
|
+
|
|
929
|
+
**HTTP endpoints** (also what the CLI uses):
|
|
930
|
+
|
|
931
|
+
```
|
|
932
|
+
GET /agents configured agent names
|
|
933
|
+
POST /agents/:name/run { prompt, sessionId?, fork? }; ?stream=1 for SSE
|
|
934
|
+
(a `session` event with the id arrives first,
|
|
935
|
+
then `agent` events, then `result`/`error`)
|
|
936
|
+
GET /agents/:name/sessions newest-first summaries (id, timestamps, preview)
|
|
937
|
+
GET /agents/:name/sessions/:id full transcript
|
|
938
|
+
POST /agents/:name/sessions/:id/abort abort the active run
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
Auth mirrors the functions surface, not the open inference endpoints: a bound
|
|
942
|
+
server requires a workspace JWT; an **unbound server rejects remote calls**
|
|
943
|
+
unless `allow_unauthenticated = true`. The server also writes an ephemeral
|
|
944
|
+
internal token to `<sessions_dir>/../runtime-token` (mode 0600) at startup —
|
|
945
|
+
same-host CLIs authenticate with it automatically, bound or not.
|
|
946
|
+
|
|
947
|
+
**Interactive CLI**:
|
|
948
|
+
|
|
949
|
+
```sh
|
|
950
|
+
bricks-buttress agent -c config.toml # list agents
|
|
951
|
+
bricks-buttress agent ops-assistant -c config.toml # chat (streams text, thinking, tool calls)
|
|
952
|
+
bricks-buttress agent ops-assistant --sessions -c config.toml
|
|
953
|
+
bricks-buttress agent ops-assistant --session <id> -c config.toml # continue
|
|
954
|
+
bricks-buttress agent ops-assistant --fork <id> -c config.toml # fork, then continue the fork
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
On a real terminal the chat runs a pi-tui interface (markdown answers, dim
|
|
958
|
+
thinking, tool-call lines, Ctrl+C aborts the running turn); pipes/scripts —
|
|
959
|
+
or `--plain` — get a line-based renderer with identical semantics.
|
|
960
|
+
|
|
961
|
+
Like function files, agent definitions are trusted input: an agent is only as
|
|
962
|
+
safe as the functions and MCP servers you hand it.
|
|
963
|
+
|
|
964
|
+
|
|
805
965
|
## Session State Cache
|
|
806
966
|
|
|
807
967
|
The server supports session state caching for ggml-llm generators, which saves KV cache state to disk after completions. This enables:
|
|
@@ -14,6 +14,8 @@ needed. On first start the server scaffolds `buttress-functions.d.ts` and a
|
|
|
14
14
|
| `simple-rag.ts` | Token chunking, embeddings, `sqlite3` + `sqlite-vec` retrieval, then completion | a chat LLM + a GGML embedding `[[generators]]` entry (below) |
|
|
15
15
|
| `transcribe-media.ts` | `context.spawn` (ffmpeg), the scratch dir, SSE progress, STT | `ffmpeg` on PATH + an STT `[[generators]]` entry |
|
|
16
16
|
| `text-to-speech.ts` | TTS (`context.buttress.synthesize`) + downloadable output (`context.fileUrl`) | an `onnx-tts` `[[generators]]` entry |
|
|
17
|
+
| `bank-note.ts` | Reading/writing the workspace's remote Data Bank (`context.bank`) | `bricks buttress bank-key` run on this host |
|
|
18
|
+
| `bank-watch-daemon.ts` | A daemon (`meta.daemon = true`): `context.bank.subscribe`, `context.setInterval`, `context.onEvent` | `bricks buttress bank-key` run on this host |
|
|
17
19
|
| `_auth.ts` | Custom auth: keep workspace tokens working, add static API keys | see the file header |
|
|
18
20
|
|
|
19
21
|
`_auth.ts` is not a function: copying it changes how every `/functions` endpoint
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Read and write a property in the workspace's remote Data Bank.
|
|
2
|
+
//
|
|
3
|
+
// Needs stored Data Bank credentials: run `bricks buttress bank-key` from a
|
|
4
|
+
// workspace-authed CLI on this host, then restart the server. Without them
|
|
5
|
+
// every `context.bank` method throws.
|
|
6
|
+
//
|
|
7
|
+
// curl -X POST http://<host>:<port>/functions/bank-note \
|
|
8
|
+
// -H 'content-type: application/json' \
|
|
9
|
+
// -d '{"propertyId": "shared-note", "text": "hello from buttress"}'
|
|
10
|
+
//
|
|
11
|
+
// Omit `text` to read the property without writing.
|
|
12
|
+
|
|
13
|
+
export const meta: ButtressFunctionMeta = {
|
|
14
|
+
description: 'Read a Data Bank property, optionally replacing its text value first',
|
|
15
|
+
parameters: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
propertyId: { type: 'string', description: 'Data Bank property id' },
|
|
19
|
+
text: { type: 'string', description: 'New value; omit to only read' },
|
|
20
|
+
},
|
|
21
|
+
required: ['propertyId'],
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default async function (
|
|
26
|
+
{ propertyId, text }: { propertyId: string; text?: string },
|
|
27
|
+
context: ButtressFunctionContext,
|
|
28
|
+
) {
|
|
29
|
+
if (text != null) {
|
|
30
|
+
// `update` replaces the stored value unconditionally (an omitted `value`
|
|
31
|
+
// clears it) — read-merge first when only part of an object should change.
|
|
32
|
+
await context.bank.update([
|
|
33
|
+
{ propertyId, value: text, updateNote: 'Updated via bank-note function' },
|
|
34
|
+
])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const property = await context.bank.get(propertyId)
|
|
38
|
+
if (!property) return { propertyId, exists: false }
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
propertyId,
|
|
42
|
+
exists: true,
|
|
43
|
+
value: property.value,
|
|
44
|
+
updateAt: property.updateAt,
|
|
45
|
+
lastUpdateNote: property.lastUpdateNote,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Daemon: watch Data Bank properties and keep a change journal.
|
|
2
|
+
//
|
|
3
|
+
// A file with `meta.daemon = true` runs as a daemon — a long-lived
|
|
4
|
+
// background function. Its default export runs ONCE when the daemon starts,
|
|
5
|
+
// and everything registered on the context keeps running after it returns.
|
|
6
|
+
// Daemons are not MCP tools and cannot be called over HTTP; their live
|
|
7
|
+
// status shows on the /status page.
|
|
8
|
+
//
|
|
9
|
+
// This one reacts to remote Data Bank changes (needs stored credentials —
|
|
10
|
+
// run `bricks buttress bank-key` on this host, then restart the server),
|
|
11
|
+
// flushes hourly, and accepts a `flush` event from other local functions:
|
|
12
|
+
//
|
|
13
|
+
// context.daemons.emit('bank-watch-daemon', 'flush')
|
|
14
|
+
|
|
15
|
+
export const meta: ButtressFunctionMeta = {
|
|
16
|
+
description: 'Journal changes to watched Data Bank properties',
|
|
17
|
+
daemon: true,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function (context: ButtressDaemonContext) {
|
|
21
|
+
let journal: { at: string; propertyId: string; value: unknown }[] = []
|
|
22
|
+
|
|
23
|
+
const flush = async () => {
|
|
24
|
+
if (journal.length === 0) return
|
|
25
|
+
const entries = journal
|
|
26
|
+
journal = []
|
|
27
|
+
// `dontNotify` keeps this daemon from waking itself up with its own write.
|
|
28
|
+
await context.bank.update([{ propertyId: 'shared-note-journal', value: entries }], {
|
|
29
|
+
dontNotify: true,
|
|
30
|
+
})
|
|
31
|
+
context.log(`flushed ${entries.length} journal entr${entries.length === 1 ? 'y' : 'ies'}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Changes to the watched ids arrive here over a shared, auto-reconnecting
|
|
35
|
+
// connection — a network drop never stops the daemon.
|
|
36
|
+
context.bank.subscribe(['shared-note'], (properties) => {
|
|
37
|
+
for (const property of properties) {
|
|
38
|
+
journal.push({
|
|
39
|
+
at: new Date().toISOString(),
|
|
40
|
+
propertyId: property.propertyId,
|
|
41
|
+
value: property.value,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
context.log(`journaled ${properties.length} change(s), ${journal.length} pending`)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// A managed setInterval: cleared automatically when the daemon stops, and
|
|
48
|
+
// a callback that throws is logged without stopping the timer.
|
|
49
|
+
context.setInterval(flush, 60 * 60 * 1000)
|
|
50
|
+
|
|
51
|
+
// Other local functions can force a flush via context.daemons.emit.
|
|
52
|
+
context.onEvent(({ event, source }) => {
|
|
53
|
+
if (event === 'flush') {
|
|
54
|
+
context.log(`flush requested by ${source}`)
|
|
55
|
+
return flush()
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
context.log('watching shared-note')
|
|
60
|
+
|
|
61
|
+
// Runs when the daemon stops (file edited/removed, or server shutdown).
|
|
62
|
+
return () => flush()
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Local functions can drive a configured agent (see [[agents]] in the server
|
|
2
|
+
// config): context.agents.run executes a full tool-using LLM loop server-side
|
|
3
|
+
// and returns the final answer plus the session id for follow-ups.
|
|
4
|
+
//
|
|
5
|
+
// The run shares this call's lifetime — the function's deadline (meta.timeout)
|
|
6
|
+
// and client disconnects abort it, including in-flight tool calls. Long agent
|
|
7
|
+
// work belongs in a daemon (no deadline) instead.
|
|
8
|
+
|
|
9
|
+
export const meta = {
|
|
10
|
+
description: 'Ask a configured agent to investigate something and return its conclusion.',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
agent: { type: 'string', description: 'Configured agent name' },
|
|
15
|
+
question: { type: 'string' },
|
|
16
|
+
sessionId: { type: 'string', description: 'Continue an earlier session (optional)' },
|
|
17
|
+
},
|
|
18
|
+
required: ['agent', 'question'],
|
|
19
|
+
},
|
|
20
|
+
timeout: '10m',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async (
|
|
24
|
+
{ agent, question, sessionId }: { agent: string; question: string; sessionId?: string },
|
|
25
|
+
context: any,
|
|
26
|
+
) => {
|
|
27
|
+
const result = await context.agents.run(agent, {
|
|
28
|
+
prompt: question,
|
|
29
|
+
sessionId,
|
|
30
|
+
// Progress mirrors onto this call's SSE stream automatically; onEvent is
|
|
31
|
+
// available for custom handling of the raw pi events.
|
|
32
|
+
})
|
|
33
|
+
return {
|
|
34
|
+
conclusion: result.content,
|
|
35
|
+
sessionId: result.sessionId,
|
|
36
|
+
turns: result.usage.totalTurns,
|
|
37
|
+
stopReason: result.stopReason,
|
|
38
|
+
}
|
|
39
|
+
}
|
package/config/sample.toml
CHANGED
|
@@ -45,6 +45,28 @@ enabled = true
|
|
|
45
45
|
# api_base = "https://example.internal"
|
|
46
46
|
# api_keys = ["a-long-random-string"] # e.g. consumed by function-samples/_auth.ts
|
|
47
47
|
|
|
48
|
+
# Agents (EXPERIMENTAL): pi-based LLM loops running in this server, with local
|
|
49
|
+
# functions (and MCP servers) as tools and config-scoped sessions on disk.
|
|
50
|
+
# Local functions call them via context.agents.run(...); an interactive CLI
|
|
51
|
+
# (`bricks-buttress agent <name> -c <config>`) drives the same agents.
|
|
52
|
+
# See the "Agents" section of README.md.
|
|
53
|
+
# [[agents]]
|
|
54
|
+
# name = "ops-assistant"
|
|
55
|
+
# model = "buttress/ggml-org/gpt-oss-20b-GGUF" # provider/model-id, split on the FIRST slash
|
|
56
|
+
# system_prompt = "You are an ops automation agent. Use the provided tools."
|
|
57
|
+
# tools = ["host-info"] # local function names (explicit; no wildcard)
|
|
58
|
+
# max_turns = 30
|
|
59
|
+
# [agents.mcp_servers.example]
|
|
60
|
+
# url = "https://example.com/mcp/"
|
|
61
|
+
# optional = true
|
|
62
|
+
|
|
63
|
+
# [agents_options]
|
|
64
|
+
# sessions_dir = "./.buttress-agent/sessions"
|
|
65
|
+
# session_max_age = "30d" # retention; 0 disables
|
|
66
|
+
# session_max_count = 500 # per agent; 0 disables
|
|
67
|
+
# max_depth = 2 # function -> agent -> function chain cap
|
|
68
|
+
# allow_unauthenticated = false # serve /agents on an UNBOUND server
|
|
69
|
+
|
|
48
70
|
[runtime]
|
|
49
71
|
cache_dir = "./.buttress-cache"
|
|
50
72
|
# huggingface_token = "hf_xx"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bricks-buttress agent` — chat client for configured agents.
|
|
3
|
+
*
|
|
4
|
+
* A thin remote client of the /agents endpoints on a RUNNING server: the agent
|
|
5
|
+
* loop, tools, and sessions all live server-side. Reads the config file only
|
|
6
|
+
* to find the server port and the local runtime token (written 0600 by the
|
|
7
|
+
* server at startup), so the same-host flow needs zero extra setup.
|
|
8
|
+
*
|
|
9
|
+
* On a real terminal the pi-tui chat UI runs (see tui.ts); pipes/scripts (or
|
|
10
|
+
* --plain) get a line-based streaming renderer with identical semantics.
|
|
11
|
+
*
|
|
12
|
+
* bricks-buttress agent list configured agents
|
|
13
|
+
* bricks-buttress agent <name> chat (new session)
|
|
14
|
+
* bricks-buttress agent <name> --session <id> continue a session
|
|
15
|
+
* bricks-buttress agent <name> --fork <id> fork then chat
|
|
16
|
+
* bricks-buttress agent <name> --sessions list sessions
|
|
17
|
+
* Common: -c/--config <path|toml>, --url <server>, --token <token>, --plain
|
|
18
|
+
*/
|
|
19
|
+
export declare const runAgentCommand: (argv: string[]) => Promise<void>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared client plumbing for the `bricks-buttress agent` front-ends (line mode
|
|
3
|
+
* and the pi-tui chat): config/token discovery, authenticated requests, SSE
|
|
4
|
+
* parsing, and the streaming run call. UI-free on purpose.
|
|
5
|
+
*/
|
|
6
|
+
export type Connection = {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
token: string | null;
|
|
9
|
+
};
|
|
10
|
+
/** Same config-argument semantics as the server CLI: a path or inline TOML. */
|
|
11
|
+
export declare const loadClientConfig: (configArg: string | null) => {
|
|
12
|
+
config: import("../types").Config;
|
|
13
|
+
configDir: string;
|
|
14
|
+
agentsConfig: import("./types").AgentsConfig | null;
|
|
15
|
+
};
|
|
16
|
+
export declare const readRuntimeToken: (file: string) => string | null;
|
|
17
|
+
export declare const request: (connection: Connection, route: string, init?: RequestInit) => Promise<Response>;
|
|
18
|
+
/** Minimal text/event-stream reader: yields { event, data } frames. */
|
|
19
|
+
export declare function readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{
|
|
20
|
+
event: string;
|
|
21
|
+
data: string;
|
|
22
|
+
}, void, unknown>;
|
|
23
|
+
export type RunFrame = {
|
|
24
|
+
event: string;
|
|
25
|
+
payload: any;
|
|
26
|
+
};
|
|
27
|
+
export type RunOutcome = {
|
|
28
|
+
kind: 'result';
|
|
29
|
+
result: any;
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'error';
|
|
32
|
+
message: string;
|
|
33
|
+
sessionId: string | null;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'aborted';
|
|
36
|
+
sessionId: string | null;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'disconnected';
|
|
39
|
+
sessionId: string | null;
|
|
40
|
+
};
|
|
41
|
+
export type StreamRunBody = {
|
|
42
|
+
prompt: string;
|
|
43
|
+
sessionId?: string;
|
|
44
|
+
fork?: boolean;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Whether a `--fork` chat has actually forked yet, i.e. whether the flag has
|
|
48
|
+
* been consumed and later prompts should continue rather than fork again.
|
|
49
|
+
*
|
|
50
|
+
* The server only forks once it accepts the run, and announces the new id
|
|
51
|
+
* (`session` event, or `result.sessionId` on an older server). A run that
|
|
52
|
+
* failed before that — a rejected token, an unknown agent, a dropped
|
|
53
|
+
* connection, an immediate Ctrl+C — leaves the client on the SOURCE session, so
|
|
54
|
+
* the flag has to survive: clearing it would make the user's retry append to
|
|
55
|
+
* the transcript `--fork` exists to keep untouched.
|
|
56
|
+
*/
|
|
57
|
+
export declare const forkTookEffect: (startedFrom: string | null, sessionId: string | null) => boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Run a prompt over the streaming endpoint, delivering every parsed frame to
|
|
60
|
+
* `onFrame` and returning the terminal outcome. Frame handler errors are the
|
|
61
|
+
* caller's problem — they propagate.
|
|
62
|
+
*/
|
|
63
|
+
export declare const streamRun: (connection: Connection, name: string, body: StreamRunBody, { signal, onFrame }: {
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
onFrame: (frame: RunFrame) => void;
|
|
66
|
+
}) => Promise<RunOutcome>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Config } from '../types';
|
|
2
|
+
import type { AgentsConfig } from './types';
|
|
3
|
+
export declare class AgentsConfigError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
export type ResolveAgentsOptions = {
|
|
7
|
+
/** Directory relative paths resolve against — the `--config` file's directory. */
|
|
8
|
+
configDir?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Resolve `[[agents]]` + `[agents_options]` into the runtime shape. Returns
|
|
12
|
+
* null when no agents are configured. Invalid definitions throw — a wrong
|
|
13
|
+
* agent config should stop startup, not silently drop an agent.
|
|
14
|
+
*/
|
|
15
|
+
export declare const resolveAgentsConfig: (config: Config, { configDir }?: ResolveAgentsOptions) => AgentsConfig | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AgentsFunctionApi, AgentsService } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* The `context.agents` surface local functions receive. Binds the caller's
|
|
4
|
+
* abort signal (function deadline / client disconnect cascades into the run),
|
|
5
|
+
* its emit stream, and its agent-invocation depth.
|
|
6
|
+
*/
|
|
7
|
+
export declare const buildAgentsFunctionApi: (service: AgentsService | null | undefined, { signal, emit, depth, }: {
|
|
8
|
+
signal: AbortSignal;
|
|
9
|
+
emit?: (event: string, data?: unknown) => void;
|
|
10
|
+
depth: number;
|
|
11
|
+
}) => AgentsFunctionApi;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process loopback for agent → buttress model traffic, plus the internal
|
|
3
|
+
* bearer token that authorizes it (and same-host CLIs) without a cloud-minted
|
|
4
|
+
* workspace JWT — a bound server only holds the issuer *public* key and
|
|
5
|
+
* cannot mint its own JWTs.
|
|
6
|
+
*/
|
|
7
|
+
export type ElysiaHandle = (request: Request) => Response | Promise<Response>;
|
|
8
|
+
export declare const generateInternalToken: () => string;
|
|
9
|
+
export declare const makeInternalTokenVerifier: (token: string) => (candidate: string | null | undefined) => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Write the internal token where a same-host `bricks-buttress agent` CLI can
|
|
12
|
+
* read it (0600, next to the sessions dir — config-scoped like everything
|
|
13
|
+
* else about agents). Rewritten on every startup: the token is ephemeral.
|
|
14
|
+
*/
|
|
15
|
+
export declare const writeRuntimeTokenFile: (file: string, token: string) => void;
|
|
16
|
+
/**
|
|
17
|
+
* Fetch-shaped dispatcher into the local Elysia app. The request never touches
|
|
18
|
+
* a socket; the internal token replaces whatever Authorization the client
|
|
19
|
+
* library synthesized so the shared auth guard accepts it on bound servers.
|
|
20
|
+
*/
|
|
21
|
+
export declare const createLoopbackFetch: (getHandle: () => ElysiaHandle | null, token: string) => typeof fetch;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentMcpServerConfig } from './types';
|
|
2
|
+
export type McpAgentTool = {
|
|
3
|
+
name: string;
|
|
4
|
+
label: string;
|
|
5
|
+
description: string;
|
|
6
|
+
parameters: Record<string, any>;
|
|
7
|
+
execute: (toolCallId: string, args: any, signal?: AbortSignal) => Promise<any>;
|
|
8
|
+
_mcpServer: string;
|
|
9
|
+
_mcpToolName: string;
|
|
10
|
+
};
|
|
11
|
+
/** `mcp__<server>__<tool>`, hashed into the cap when too long or colliding. */
|
|
12
|
+
export declare const qualifyToolName: (serverName: string, toolName: string, usedNames: Set<string>) => string;
|
|
13
|
+
export type McpManager = {
|
|
14
|
+
/**
|
|
15
|
+
* Tools for every configured server of an agent. Fail-closed: a server that
|
|
16
|
+
* won't connect rejects the whole call unless it is marked `optional`.
|
|
17
|
+
*/
|
|
18
|
+
toolsFor: (agentName: string, servers: Record<string, AgentMcpServerConfig>) => Promise<McpAgentTool[]>;
|
|
19
|
+
dispose: () => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
export declare const createMcpManager: ({ configDir }: {
|
|
22
|
+
configDir: string;
|
|
23
|
+
}) => McpManager;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Model, type MutableModels } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { Config } from '../types';
|
|
3
|
+
import type { AgentDefinition } from './types';
|
|
4
|
+
export declare const BUTTRESS_PROVIDER_ID = "buttress";
|
|
5
|
+
/**
|
|
6
|
+
* Loopback requests never leave the process: this host name only exists so the
|
|
7
|
+
* OpenAI client has a syntactically valid base URL to resolve paths against.
|
|
8
|
+
* The injected fetch dispatches the request straight into the Elysia app.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LOOPBACK_BASE_URL = "http://buttress.internal/oai-compat/v1";
|
|
11
|
+
/** Fetch-shaped dispatcher into the local Elysia app (see loopback.ts). */
|
|
12
|
+
export type LoopbackFetch = typeof fetch;
|
|
13
|
+
/**
|
|
14
|
+
* A `Models` collection with every pi built-in provider (env-var API keys)
|
|
15
|
+
* plus the `buttress` pseudo-provider whose models are the configured LLM
|
|
16
|
+
* generators, streamed over the in-process OpenAI-compat loopback.
|
|
17
|
+
*/
|
|
18
|
+
export declare const buildAgentModels: (config: Config, loopbackFetch: LoopbackFetch) => MutableModels;
|
|
19
|
+
/** Resolve an agent's model reference against the registry, with clear errors. */
|
|
20
|
+
export declare const resolveAgentModel: (models: MutableModels, agent: AgentDefinition) => Model<any>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Config } from '../types';
|
|
2
|
+
import type { FunctionsService } from '../functions';
|
|
3
|
+
import type { FunctionRuntime } from '../functions/types';
|
|
4
|
+
import { type ElysiaHandle } from './loopback';
|
|
5
|
+
import { type AgentsConfig, type AgentsService } from './types';
|
|
6
|
+
export type CreateAgentsServiceOptions = {
|
|
7
|
+
config: Config;
|
|
8
|
+
agentsConfig: AgentsConfig;
|
|
9
|
+
/** Server runtime handed to function tool calls; `agents` is added here. */
|
|
10
|
+
runtimeBase: Omit<FunctionRuntime, 'agents'>;
|
|
11
|
+
/** Late-bound: the Elysia app exists only after the service (circular wiring). */
|
|
12
|
+
getHandle: () => ElysiaHandle | null;
|
|
13
|
+
/** Late-bound for the same reason (functions service needs the agents service). */
|
|
14
|
+
getFunctions: () => FunctionsService | null;
|
|
15
|
+
};
|
|
16
|
+
export declare const createAgentsService: ({ config, agentsConfig, runtimeBase, getHandle, getFunctions, }: CreateAgentsServiceOptions) => AgentsService;
|