tardie 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +178 -4
- package/package.json +53 -5
- package/src/agent/artifact.ts +24 -0
- package/src/agent/boundary.ts +34 -0
- package/src/agent/budget.ts +131 -0
- package/src/agent/capability.ts +281 -0
- package/src/agent/compaction.ts +276 -0
- package/src/agent/contract.ts +38 -0
- package/src/agent/events.ts +287 -0
- package/src/agent/index.ts +68 -0
- package/src/agent/infer.ts +246 -0
- package/src/agent/reply.ts +66 -0
- package/src/agent/request.ts +191 -0
- package/src/agent/resume.ts +39 -0
- package/src/agent/spawn.ts +312 -0
- package/src/agent/tools.ts +154 -0
- package/src/agent/turn.ts +60 -0
- package/src/agent/usage.ts +197 -0
- package/src/bun/file.ts +45 -0
- package/src/bun/host.ts +313 -0
- package/src/bun/otlp.ts +18 -0
- package/src/bun/workspace.ts +117 -0
- package/src/cli/assets.ts +61 -0
- package/src/cli/build.ts +107 -0
- package/src/cli/commands.ts +448 -0
- package/src/cli/config.ts +150 -0
- package/src/cli/dev.ts +146 -0
- package/src/cli/main.ts +24 -0
- package/src/cli/push.ts +107 -0
- package/src/cli/render.ts +95 -0
- package/src/cli/services.ts +32 -0
- package/src/cli/setup.ts +278 -0
- package/src/cli/version.ts +37 -0
- package/src/client/client.ts +288 -0
- package/src/client/contract.ts +411 -0
- package/src/client/index.ts +37 -0
- package/src/client/problem.ts +58 -0
- package/src/client/stream.ts +116 -0
- package/src/code/contract.ts +111 -0
- package/src/code/defaults.ts +12 -0
- package/src/code/errors.ts +12 -0
- package/src/code/events.ts +133 -0
- package/src/code/execute.ts +335 -0
- package/src/code/fetch.ts +165 -0
- package/src/code/files.ts +268 -0
- package/src/code/ids.ts +15 -0
- package/src/code/index.ts +4 -0
- package/src/code/packages.ts +142 -0
- package/src/code/projections.ts +108 -0
- package/src/code/sandbox.ts +149 -0
- package/src/code/store.ts +89 -0
- package/src/code/turns.ts +113 -0
- package/src/code/workspace.ts +194 -0
- package/src/core/actor.ts +157 -0
- package/src/core/event-log.ts +72 -0
- package/src/core/event.ts +9 -0
- package/src/core/facets.ts +19 -0
- package/src/core/message.ts +68 -0
- package/src/core/router.ts +55 -0
- package/src/core/runtime.ts +30 -0
- package/src/core/trace.ts +34 -0
- package/src/host/deadlock.ts +89 -0
- package/src/host/host.ts +222 -0
- package/src/model/model.ts +688 -0
- package/src/server/actor.ts +102 -0
- package/src/server/api.ts +340 -0
- package/src/server/config.ts +83 -0
- package/src/server/contract.ts +76 -0
- package/src/server/host.ts +337 -0
- package/src/server/http.ts +190 -0
- package/src/server/main.ts +27 -0
- package/src/server/problem.ts +30 -0
- package/src/server/projections.ts +148 -0
- package/ui/assets/index-BCrEEGCU.css +1 -0
- package/ui/assets/index-D12zyLjs.js +21 -0
- package/ui/index.html +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clavia, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,9 +1,183 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<br>
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
|
|
5
|
+
<img alt="Tardigrade logo: a tardigrade drawn from overlapping circles" src="docs/assets/logo-light.svg" width="170">
|
|
6
|
+
</picture>
|
|
7
|
+
</p>
|
|
2
8
|
|
|
3
|
-
|
|
9
|
+
# Tardigrade
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
[](https://www.npmjs.com/package/tardie)
|
|
12
|
+
|
|
13
|
+
A durable and modular agent harness built for self-improvement.
|
|
14
|
+
|
|
15
|
+
### A harness made for self-improvement
|
|
16
|
+
As models get increasingly smart, they will be capable of writing their own harnesses to improve themselves ([Meta-Harness](https://arxiv.org/abs/2603.28052)). A harness that is too rigid and complex is a bottleneck to this. We need something more composable, and easy to author.
|
|
17
|
+
|
|
18
|
+
We took inspiration from React. React derives the component tree as a function of state (`UI = f(state)`). Similarly, Tardigrade defines the harness as a set of state transitions derived from the event log, an idea with roots in [Harel's statecharts](https://www.sciencedirect.com/science/article/pii/0167642387900359).
|
|
19
|
+
|
|
20
|
+
$$\lbrace\mathrm{transitions}\rbrace = f(\mathrm{log})$$
|
|
21
|
+
|
|
22
|
+
## Why Tardigrade
|
|
23
|
+
|
|
24
|
+
- **Composable harness.** Add tools, code execution, budgets, compaction, and replies as independent capabilities.
|
|
25
|
+
- **Strongly typed, built on Effect.** Typed services and Layers make each capability's dependencies explicit. A missing service fails during compile.
|
|
26
|
+
- **Crash proof.** A durable host derives unfinished work from the stored log.
|
|
27
|
+
- **Serverless.** All you need is a durable store, no process has to stay alive. Any new invocation reads the log, runs the transitions it owes, and settles.
|
|
28
|
+
- **Inspect and improve every run.** Log as core supports native debugging, replay, and experiments with state forked from any checkpoint.
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
Install Tardigrade and run the default agent. Use Bun 1.4 or later.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun add -g tardie
|
|
36
|
+
tdg dev
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Start a thread from another shell:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
tdg run "read this repo and tell me what it does"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Watch the live trajectory at [localhost:4242](http://localhost:4242).
|
|
46
|
+
|
|
47
|
+
<picture>
|
|
48
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/voyager-dark.png">
|
|
49
|
+
<img alt="The voyager: a thread's log, one row per event" src="docs/assets/voyager-light.png">
|
|
50
|
+
</picture>
|
|
51
|
+
|
|
52
|
+
## Build one
|
|
6
53
|
|
|
7
54
|
```bash
|
|
8
|
-
|
|
55
|
+
bun add tardie
|
|
9
56
|
```
|
|
57
|
+
|
|
58
|
+
You can use `npm install tardie` instead. Install `tardie@next` to test a release candidate.
|
|
59
|
+
|
|
60
|
+
### Create a capability
|
|
61
|
+
|
|
62
|
+
An agent is made of capabilities. A capability is one value with two halves: what the model is shown (`tools`, `system`), and how the calls that come back are handled (`serve`). This one gives the model a single tool:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import type { Capability } from "tardie"
|
|
66
|
+
|
|
67
|
+
const deploys: Capability = {
|
|
68
|
+
name: "deploys",
|
|
69
|
+
tools: () => [
|
|
70
|
+
{
|
|
71
|
+
name: "recent_deploys",
|
|
72
|
+
description: "List recent production deploys",
|
|
73
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
serve: (call, log, answer) => {
|
|
77
|
+
if (call.name !== "recent_deploys") return undefined
|
|
78
|
+
return [answer([{ service: "api", revision: "a17c", summary: "Add rate limiting" }])]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`tools` derives what the model is offered from the log; a constant capability ignores it. `serve` handles a call that comes back: `answer` mints the transition that records the result, and returning `undefined` passes the call to the next capability. Replace the sample result with a call to your deployment API.
|
|
84
|
+
|
|
85
|
+
The call follows one route:
|
|
86
|
+
|
|
87
|
+
1. `tools` adds `recent_deploys` to the next model request.
|
|
88
|
+
2. The model selects it and returns a tool call. Tardigrade records `ToolCalled` in the log.
|
|
89
|
+
3. The shared router asks each mounted capability's `serve` to handle the call. `deploys` matches the name and answers.
|
|
90
|
+
4. Tardigrade records `ToolReturned`. The next model request includes the result.
|
|
91
|
+
|
|
92
|
+
### Compose an agent
|
|
93
|
+
|
|
94
|
+
Mount the capability beside the built-in parts that this task needs:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { agentOf, budget, codeMode, compaction, reply } from "tardie"
|
|
98
|
+
|
|
99
|
+
const releaseAnalyst = agentOf([
|
|
100
|
+
deploys, // recent_deploys and its handler
|
|
101
|
+
codeMode, // durable JavaScript execution
|
|
102
|
+
budget, // a per-turn code budget
|
|
103
|
+
compaction, // bounded model context
|
|
104
|
+
reply // results for parent agents
|
|
105
|
+
])
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`agentOf` combines every model-facing part and runtime handler. The model sees `recent_deploys` and `execute` in one request. Policy capabilities react to the same log.
|
|
109
|
+
|
|
110
|
+
This agent can inspect deployments, analyze results with JavaScript, compact a long investigation, and report to a parent agent. Change the list to create another harness.
|
|
111
|
+
|
|
112
|
+
A run can follow this path:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
MessageReceived -> recent_deploys -> execute -> TurnCompleted
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Each action and result becomes an event that every capability can interpret.
|
|
119
|
+
|
|
120
|
+
### Run the composition
|
|
121
|
+
|
|
122
|
+
<details>
|
|
123
|
+
<summary>Bind a model and durable SQLite host</summary>
|
|
124
|
+
|
|
125
|
+
The three code blocks form one program.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { infer } from "tardie/model"
|
|
129
|
+
import { createBunHost } from "tardie/bun/host"
|
|
130
|
+
|
|
131
|
+
const model = infer({
|
|
132
|
+
baseUrl: process.env.MODEL_BASE_URL!,
|
|
133
|
+
apiKey: process.env.MODEL_API_KEY!,
|
|
134
|
+
model: process.env.MODEL_ID!
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const host = await createBunHost({
|
|
138
|
+
log: "agents.sqlite",
|
|
139
|
+
actorFor: () => releaseAnalyst,
|
|
140
|
+
layersFor: () => model
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await host.deliver("bun:main", {
|
|
144
|
+
type: "MessageReceived",
|
|
145
|
+
id: "m1",
|
|
146
|
+
text: "What changed in the deploy?",
|
|
147
|
+
at: Date.now()
|
|
148
|
+
})
|
|
149
|
+
await host.drive()
|
|
150
|
+
|
|
151
|
+
const completed = (await host.read("main")).findLast(
|
|
152
|
+
(event) => event.type === "TurnCompleted"
|
|
153
|
+
)
|
|
154
|
+
console.log(completed)
|
|
155
|
+
await host.close()
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The model binding uses the OpenAI-compatible protocol by default. Add `provider: "bedrock"` to the model options for Bedrock.
|
|
159
|
+
|
|
160
|
+
</details>
|
|
161
|
+
|
|
162
|
+
## How durability works
|
|
163
|
+
|
|
164
|
+
Every message, model action, tool result, and checkpoint lands in the log. Reactors read that log and derive keyed transitions.
|
|
165
|
+
|
|
166
|
+
$$\lbrace\mathrm{transitions}\rbrace = f(\mathrm{log})$$
|
|
167
|
+
|
|
168
|
+
The host runs transitions with unrecorded keys. It appends the returned events and repeats until the agent rests.
|
|
169
|
+
|
|
170
|
+
If the process stops during `recent_deploys`, the log still contains its unanswered `ToolCalled`. `host.recover()` derives the same key and input, then runs the handler again.
|
|
171
|
+
|
|
172
|
+
Effects have at-least-once execution. Each keyed result is recorded once. Providers can use the transition key as an idempotency key.
|
|
173
|
+
|
|
174
|
+
## Learn more
|
|
175
|
+
|
|
176
|
+
- [Quickstart](docs/quickstart.md): build the event loop and its agent capabilities from first principles.
|
|
177
|
+
- [HTTP server](docs/how-to/server.md)
|
|
178
|
+
- [CLI](docs/how-to/cli.md)
|
|
179
|
+
- [Why Tardigrade](docs/explanations/why.md): learn what the log-as-state model makes possible.
|
|
180
|
+
|
|
181
|
+
## Contributing
|
|
182
|
+
|
|
183
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md) and run `bun run gate` before finishing a change.
|
package/package.json
CHANGED
|
@@ -1,10 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tardie",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Reserved for tardigrade, a durable agent harness. See @clavia/tardigrade.",
|
|
3
|
+
"version": "0.1.0",
|
|
5
4
|
"license": "MIT",
|
|
5
|
+
"author": "Clavia, Inc.",
|
|
6
|
+
"description": "A durable agent harness. State is a pure function of the log.",
|
|
6
7
|
"homepage": "https://github.com/clavia-labs/tardigrade#readme",
|
|
7
|
-
"repository": {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/clavia-labs/tardigrade.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/clavia-labs/tardigrade/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"ui"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"bun": ">=1.4.0"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"tdg": "./src/cli/main.ts"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./src/agent/index.ts",
|
|
31
|
+
"./package.json": "./package.json",
|
|
32
|
+
"./core/*": "./src/core/*.ts",
|
|
33
|
+
"./code": "./src/code/index.ts",
|
|
34
|
+
"./code/*": "./src/code/*.ts",
|
|
35
|
+
"./host/*": "./src/host/*.ts",
|
|
36
|
+
"./client": "./src/client/index.ts",
|
|
37
|
+
"./client/*": "./src/client/*.ts",
|
|
38
|
+
"./bun/*": "./src/bun/*.ts",
|
|
39
|
+
"./server/*": "./src/server/*.ts",
|
|
40
|
+
"./cli/*": "./src/cli/*.ts",
|
|
41
|
+
"./model": "./src/model/model.ts",
|
|
42
|
+
"./model/*": "./src/model/*.ts",
|
|
43
|
+
"./*": "./src/agent/*.ts"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@aws-sdk/client-bedrock-runtime": "^3.1079.0",
|
|
47
|
+
"@cfworker/json-schema": "^4.1.1",
|
|
48
|
+
"@effect/platform-bun": "4.0.0-rc.110",
|
|
49
|
+
"@effect/platform-node-shared": "4.0.0-rc.110",
|
|
50
|
+
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
|
|
51
|
+
"@smithy/fetch-http-handler": "^5.6.3",
|
|
52
|
+
"@smithy/node-http-handler": "^4.11.2",
|
|
53
|
+
"@tanstack/ai": "0.46.0",
|
|
54
|
+
"@tanstack/ai-bedrock": "0.2.3",
|
|
55
|
+
"@tanstack/ai-openai": "0.20.0",
|
|
56
|
+
"effect": "4.0.0-rc.110"
|
|
57
|
+
}
|
|
10
58
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Actor } from "tardie/core/actor"
|
|
2
|
+
|
|
3
|
+
export const ACTOR_NAME_PATTERN = /^[a-z][a-z0-9-]{0,62}$/u
|
|
4
|
+
|
|
5
|
+
export const ACTOR_ARTIFACT_VERSION = 1
|
|
6
|
+
|
|
7
|
+
export interface ActorArtifactManifest {
|
|
8
|
+
readonly schema: typeof ACTOR_ARTIFACT_VERSION
|
|
9
|
+
readonly name: string
|
|
10
|
+
readonly module: string
|
|
11
|
+
readonly digest: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ActorDefinition<R = never> {
|
|
15
|
+
readonly name: string
|
|
16
|
+
readonly actor: Actor<R>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const defineActor = <R>(definition: ActorDefinition<R>): ActorDefinition<R> => {
|
|
20
|
+
if (!ACTOR_NAME_PATTERN.test(definition.name)) {
|
|
21
|
+
throw new Error(`actor name must match ${String(ACTOR_NAME_PATTERN)}, got ${JSON.stringify(definition.name)}`)
|
|
22
|
+
}
|
|
23
|
+
return definition
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Event } from "tardie/core/event"
|
|
2
|
+
import { turnTerminalOf } from "tardie/code/turns"
|
|
3
|
+
|
|
4
|
+
// Boundary is where a settle left a turn: a terminal, or a park on a budget ask. The
|
|
5
|
+
// platform's call and resume read it to answer the spawning code. Pure over the log, so a
|
|
6
|
+
// re-driven settle reads the same boundary.
|
|
7
|
+
|
|
8
|
+
export type Boundary =
|
|
9
|
+
| { readonly kind: "completed"; readonly output: string }
|
|
10
|
+
| { readonly kind: "failed"; readonly error: string }
|
|
11
|
+
| { readonly kind: "requesting"; readonly callId: string; readonly reason: string; readonly amount: number }
|
|
12
|
+
|
|
13
|
+
// boundaryOf returns the turn's boundary, or undefined while it still runs. A terminal wins
|
|
14
|
+
// over a park: a resumed turn that finished reads completed even though it once asked. A park
|
|
15
|
+
// is the last BudgetRequested no grant or denial has answered.
|
|
16
|
+
export const boundaryOf = (log: ReadonlyArray<Event>, turn: string): Boundary | undefined => {
|
|
17
|
+
const terminal = turnTerminalOf(log, turn)
|
|
18
|
+
if (terminal !== undefined) {
|
|
19
|
+
return terminal.type === "TurnCompleted"
|
|
20
|
+
? { kind: "completed", output: String((terminal as { output?: unknown }).output) }
|
|
21
|
+
: { kind: "failed", error: String((terminal as { error?: unknown }).error) }
|
|
22
|
+
}
|
|
23
|
+
let pending: Event | undefined
|
|
24
|
+
for (const e of log) {
|
|
25
|
+
if (String((e as { turn?: unknown }).turn) !== turn) continue
|
|
26
|
+
if (e.type === "BudgetRequested") pending = e
|
|
27
|
+
else if (e.type === "BudgetGranted" || e.type === "BudgetDenied") pending = undefined
|
|
28
|
+
}
|
|
29
|
+
if (pending !== undefined) {
|
|
30
|
+
const p = pending as { callId?: unknown; reason?: unknown; amount?: unknown }
|
|
31
|
+
return { kind: "requesting", callId: String(p.callId), reason: String(p.reason ?? ""), amount: Number(p.amount ?? 0) }
|
|
32
|
+
}
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Clock, Effect } from "effect"
|
|
2
|
+
import { transition, type Reactor } from "tardie/core/actor"
|
|
3
|
+
import { budgetExhausted } from "./events"
|
|
4
|
+
import type { Event } from "tardie/core/event"
|
|
5
|
+
import { turnHead, turnView } from "tardie/code/turns"
|
|
6
|
+
|
|
7
|
+
// The budget reactor observes the turn's tool spend and fires BudgetExhausted once when it
|
|
8
|
+
// passes the brief's budget. Detection lives here; enforcement lives with the tools reactor,
|
|
9
|
+
// which reads the wall and refuses the next execute. See docs/agent-budgets.md.
|
|
10
|
+
|
|
11
|
+
// BudgetPolicy is the ceiling a brief with no stated budget takes, so an unbounded agent is
|
|
12
|
+
// never an accident. A brief that states its own `budget` overrides it per turn; this is what a
|
|
13
|
+
// silent brief gets, and a consumer sets it on the reactor (`budgetReactorFor`) and on the spawn
|
|
14
|
+
// package, which draws the same default for a child (spawn.ts).
|
|
15
|
+
export interface BudgetPolicy {
|
|
16
|
+
readonly defaultToolBudget: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_BUDGET_POLICY: BudgetPolicy = { defaultToolBudget: 40 }
|
|
20
|
+
|
|
21
|
+
export const budgetPolicyOf = (policy: Partial<BudgetPolicy> = {}): BudgetPolicy => ({
|
|
22
|
+
defaultToolBudget: policy.defaultToolBudget ?? DEFAULT_BUDGET_POLICY.defaultToolBudget
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// budgetOf returns the turn's budget: the head's `budget` plus every grant the escalation has
|
|
26
|
+
// added, or the policy's default. The budget rides the brief envelope and is read off the raw
|
|
27
|
+
// event. A `BudgetGranted` raises the ceiling, which is what lets a granted turn resume.
|
|
28
|
+
export const budgetOf = (view: ReadonlyArray<Event>, policy: Partial<BudgetPolicy> = {}): number => {
|
|
29
|
+
const head = turnHead(view) as { budget?: unknown } | undefined
|
|
30
|
+
const base =
|
|
31
|
+
typeof head?.budget === "number" && head.budget > 0
|
|
32
|
+
? Math.floor(head.budget)
|
|
33
|
+
: budgetPolicyOf(policy).defaultToolBudget
|
|
34
|
+
const granted = view.reduce((n, e) => (e.type === "BudgetGranted" ? n + Number((e as { amount?: unknown }).amount ?? 0) : n), 0)
|
|
35
|
+
return base + granted
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// usedOf counts the tool calls the turn has spent. Only `execute` spends: `answer` and
|
|
39
|
+
// `request_budget` are the turn's exits, so they never draw the budget down.
|
|
40
|
+
export const usedOf = (view: ReadonlyArray<Event>): number =>
|
|
41
|
+
view.filter((e) => e.type === "ToolCalled" && String((e as { name?: unknown }).name) === "execute").length
|
|
42
|
+
|
|
43
|
+
// escalatableOf reports whether the brief lets this turn escalate at its wall. It rides the
|
|
44
|
+
// envelope like `budget`.
|
|
45
|
+
export const escalatableOf = (view: ReadonlyArray<Event>): boolean =>
|
|
46
|
+
(turnHead(view) as { escalatable?: unknown } | undefined)?.escalatable === true
|
|
47
|
+
|
|
48
|
+
// shadowOf reports whether this turn's brief carries the shadow flag. It rides the envelope like
|
|
49
|
+
// `escalatable`: the run's fire sets it once, and every spawn downstream inherits the same
|
|
50
|
+
// reading.
|
|
51
|
+
export const shadowOf = (view: ReadonlyArray<Event>): boolean =>
|
|
52
|
+
(turnHead(view) as { shadow?: unknown } | undefined)?.shadow === true
|
|
53
|
+
|
|
54
|
+
// worldOf returns the explicit world label this turn's brief carries, present when its fire
|
|
55
|
+
// named a shared world (docs/worlds.md). It rides the envelope like `shadow` and propagates to
|
|
56
|
+
// every spawn the same way, so a whole family stays on one shared world's facets. It is absent
|
|
57
|
+
// for the anonymous case, where a shadow run's own family.run is its world and needs no
|
|
58
|
+
// propagation.
|
|
59
|
+
export const worldOf = (view: ReadonlyArray<Event>): string | undefined => {
|
|
60
|
+
const w = (turnHead(view) as { world?: unknown } | undefined)?.world
|
|
61
|
+
return typeof w === "string" && w !== "" ? w : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// BudgetPhase is the budget state of the current turn, read from the most recent lifecycle
|
|
65
|
+
// marker scanning back. It is pure and scoped to the current turn like `outputSchemaOf`, so an
|
|
66
|
+
// earlier turn's wall does not leak. `exhausted`: the wall is up and the turn may still ask.
|
|
67
|
+
// `denied`: the ask was refused, so the turn must answer. `spending`: a grant reopened the
|
|
68
|
+
// budget, or none was ever spent.
|
|
69
|
+
export type BudgetPhase = "spending" | "exhausted" | "denied"
|
|
70
|
+
|
|
71
|
+
export const budgetPhase = (trajectory: ReadonlyArray<Event>): BudgetPhase => {
|
|
72
|
+
for (let i = trajectory.length - 1; i >= 0; i--) {
|
|
73
|
+
const t = trajectory[i]!.type
|
|
74
|
+
if (t === "BudgetExhausted") return "exhausted"
|
|
75
|
+
if (t === "BudgetDenied") return "denied"
|
|
76
|
+
if (t === "BudgetGranted") return "spending"
|
|
77
|
+
if (t === "MessageReceived") return "spending"
|
|
78
|
+
}
|
|
79
|
+
return "spending"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// budgetSpent reports whether `execute` is withdrawn for this turn: true from the wall until a
|
|
83
|
+
// grant reopens it. The tools gate and the model's tool list both read this one predicate, so
|
|
84
|
+
// they never disagree.
|
|
85
|
+
export const budgetSpent = (trajectory: ReadonlyArray<Event>): boolean => budgetPhase(trajectory) !== "spending"
|
|
86
|
+
|
|
87
|
+
// canRequestBudget reports whether the model may ask for more: only while the wall is up, no
|
|
88
|
+
// denial has closed it, and the brief made the turn escalatable. A denied turn answers and never
|
|
89
|
+
// asks again.
|
|
90
|
+
export const canRequestBudget = (trajectory: ReadonlyArray<Event>): boolean =>
|
|
91
|
+
budgetPhase(trajectory) === "exhausted" && escalatableOf(trajectory)
|
|
92
|
+
|
|
93
|
+
// overBudget reports whether the spend has passed the budget. It is pure and total over the log,
|
|
94
|
+
// so replay re-folds to the same state: it reads only the log and calls no clock and no random
|
|
95
|
+
// source. It is off by exactly one on purpose: `used > budget` fires on the call after the last
|
|
96
|
+
// allowed one, so the agent gets `budget` dispatched calls and the next is refused.
|
|
97
|
+
const overBudget = (log: ReadonlyArray<Event>, policy: BudgetPolicy): boolean => usedOf(log) > budgetOf(log, policy)
|
|
98
|
+
|
|
99
|
+
// budgetReactorFor derives the wall when the spend has crossed the ceiling and no wall marker
|
|
100
|
+
// stands. The act records `BudgetExhausted` once; the tools reactor reads it and refuses the
|
|
101
|
+
// next `execute`, so enforcement stays where dispatch lives and this reactor only detects. The
|
|
102
|
+
// wall's key is the ceiling it fired at: a grant raises the ceiling, so a second crossing is a
|
|
103
|
+
// new occurrence with a new key, and a redelivered wall absorbs.
|
|
104
|
+
export const budgetReactorFor = (policy: Partial<BudgetPolicy> = {}): Reactor<never> => (log) => {
|
|
105
|
+
const resolved = budgetPolicyOf(policy)
|
|
106
|
+
const view = turnView(log)
|
|
107
|
+
if (view.length === 0 || !overBudget(view, resolved) || budgetPhase(view) !== "spending") return []
|
|
108
|
+
const head = turnHead(view) as { id?: unknown } | undefined
|
|
109
|
+
const budget = budgetOf(view, resolved)
|
|
110
|
+
return [
|
|
111
|
+
transition({
|
|
112
|
+
key: `bw:${String(head?.id ?? "")}/${budget}`,
|
|
113
|
+
input: { turn: head?.id === undefined ? undefined : String(head.id), budget, used: usedOf(view) },
|
|
114
|
+
act: (input) =>
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const at = yield* Clock.currentTimeMillis
|
|
117
|
+
return [
|
|
118
|
+
budgetExhausted({
|
|
119
|
+
budget: input.budget,
|
|
120
|
+
used: input.used,
|
|
121
|
+
...(input.turn === undefined ? {} : { turn: input.turn }),
|
|
122
|
+
at
|
|
123
|
+
})
|
|
124
|
+
]
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// budgetReactor is that reactor on the default ceiling.
|
|
131
|
+
export const budgetReactor: Reactor<never> = budgetReactorFor()
|