@gjermundgaraba/effect-actions 0.16.0 → 0.18.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/README.md +3 -1
- package/dist/Action.d.mts +3 -5
- package/dist/Action.mjs +3 -1
- package/dist/ActionCli.d.mts +31 -42
- package/dist/ActionCli.mjs +6 -16
- package/dist/ActionHttp.d.mts +12 -8
- package/dist/ActionHttp.mjs +3 -1
- package/dist/ActionMcp.d.mts +1 -1
- package/dist/ActionMcp.mjs +4 -1
- package/dist/ActionRpc.d.mts +5 -3
- package/dist/ActionRpc.mjs +2 -1
- package/dist/ActionToolkit.d.mts +3 -2
- package/dist/ActionToolkit.mjs +3 -1
- package/dist/Authentication.d.mts +1 -1
- package/dist/Authentication.mjs +1 -1
- package/dist/authentication/provider.d.mts +2 -2
- package/dist/authentication/refusal.mjs +1 -1
- package/dist/cli/commands.d.mts +4 -3
- package/dist/cli/commands.mjs +6 -5
- package/dist/contract/ast.mjs +10 -0
- package/dist/contract/call.d.mts +1 -1
- package/dist/contract/caller.d.mts +9 -0
- package/dist/contract/caller.mjs +12 -0
- package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
- package/dist/contract/declared.mjs +42 -0
- package/dist/contract/hints.mjs +14 -0
- package/dist/contract/implementation.d.mts +4 -3
- package/dist/contract/implementation.mjs +4 -8
- package/dist/contract/names.mjs +25 -0
- package/dist/contract/status.mjs +18 -0
- package/dist/http/client.d.mts +19 -9
- package/dist/http/client.mjs +6 -1
- package/dist/mcp/client.d.mts +52 -12
- package/dist/mcp/client.mjs +62 -19
- package/dist/mcp/protocol.mjs +1 -1
- package/docs/Action.md +17 -3
- package/docs/ActionCli-failures.md +10 -9
- package/docs/ActionCli.md +22 -21
- package/docs/ActionHttp-failures.md +6 -0
- package/docs/ActionHttp.md +9 -4
- package/docs/ActionMcp.md +3 -3
- package/docs/ActionToolkit.md +25 -2
- package/docs/Authentication-failures.md +1 -1
- package/docs/CONTEXT.md +2 -1
- package/docs/README.md +4 -4
- package/docs/Testing.md +3 -1
- package/examples/README.md +10 -2
- package/examples/assistant/README.md +24 -20
- package/examples/assistant/src/agent.ts +11 -7
- package/examples/assistant/src/cli.ts +2 -2
- package/examples/assistant/src/platform/tasks-file.ts +38 -0
- package/examples/assistant/src/tasks/store.ts +6 -38
- package/examples/assistant/src/toolkit.ts +11 -1
- package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
- package/examples/kitchen-sink/src/cli.ts +19 -21
- package/examples/mcp-tools/README.md +14 -11
- package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
- package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
- package/examples/mcp-tools/src/printers/farm.ts +6 -122
- package/examples/mcp-tools/src/server.ts +4 -4
- package/examples/mcp-tools/src/stdio.ts +4 -4
- package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
- package/examples/mcp-tools/test/routes.test.ts +4 -4
- package/examples/notes-service/README.md +34 -2
- package/examples/notes-service/src/cli.ts +3 -9
- package/examples/notes-service/src/commands.ts +40 -36
- package/examples/notes-service/src/identity/authorize.ts +1 -0
- package/examples/notes-service/src/notes/Notes.ts +22 -5
- package/examples/notes-service/src/notes/handlers.ts +2 -2
- package/examples/notes-service/src/notes/store.ts +36 -6
- package/examples/notes-service/test/commands.test.ts +13 -19
- package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
- package/examples/notes-service/test/routes.test.ts +40 -1
- package/package.json +5 -5
- package/dist/_virtual/_rolldown/runtime.mjs +0 -13
- package/dist/contract/rules.mjs +0 -100
package/docs/ActionToolkit.md
CHANGED
|
@@ -44,7 +44,7 @@ const turn = chat.generateText({ prompt, toolkit }).pipe(actingFor(member));
|
|
|
44
44
|
conversations.pipe(
|
|
45
45
|
Effect.provide(model),
|
|
46
46
|
// Once, around the program. Startup services only, never a caller.
|
|
47
|
-
Effect.provide(layer.pipe(Layer.provide([
|
|
47
|
+
Effect.provide(layer.pipe(Layer.provide([tasksFile, Permissions.layerMemory]))),
|
|
48
48
|
);
|
|
49
49
|
```
|
|
50
50
|
|
|
@@ -53,10 +53,32 @@ conversations.pipe(
|
|
|
53
53
|
- A refusal or a declared error is not raised: it is a tool result with `isFailure: true`, which the model reads. So are arguments that do not decode, an `AiError` (`ToolParameterValidationError`), not `InvalidInput`: neither `authorize` nor the handler ran.
|
|
54
54
|
- A turn's tool calls, the approved calls of an answering turn included, run concurrently unless `generateText` is given a `concurrency`. A service they share must tolerate it: [assistant/src/tasks/store.ts](../examples/assistant/src/tasks/store.ts) locks each change.
|
|
55
55
|
- Arguments the input does not declare are dropped, where HTTP and a CLI refuse them ([guarantees.md](guarantees.md#wire-behavior)): a misspelled optional argument is ignored silently.
|
|
56
|
-
- Beside other tools, merge natively and provide every handler layer: `Toolkit.merge(toolkit, others)` with `Layer.mergeAll(layer, othersHandlers)`.
|
|
57
56
|
- **A model loop behind a route.** Yield `toolkit` and the model in the implementation's builder, `Action.implement(Ask, Effect.map(toolkit, (tools) => (input) => ...))`: what the handler itself yields is owed per request. Provide `layer` to the route's layer, `ActionHttp.layer(Http, ask).pipe(Layer.provide(layer))`, not in the handler, so the tools share the routes' build ([Action-in-process.md](Action-in-process.md#share-one-build-across-servers)). A tool call runs as the request's caller, unless the handler provides another around it. Keep each caller's `Chat` in a service between requests ([kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts)), and answer an approval in a later one: [kitchen-sink/src/assistant/handlers.ts](../examples/kitchen-sink/src/assistant/handlers.ts), with a scripted model in [demo-model.ts](../examples/kitchen-sink/src/assistant/demo-model.ts), which the entrypoint provides through [kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts), and tested over HTTP in [kitchen-sink/test/assistant/assistant.test.ts](../examples/kitchen-sink/test/assistant/assistant.test.ts). Name the files as it does, the route's contract `Assistant.ts` and the loop `handlers.ts`, from the first one written: a lowercase `assistant.ts` beside the contract fails with TS1149 ([setup-failures.md](setup-failures.md)).
|
|
58
57
|
- **The real model comes later?** Provide a scripted one from the entrypoint until then, as the kitchen-sink does. Reading the model with `Effect.serviceOption` hides the requirement: the server builds without one and every request fails.
|
|
59
58
|
|
|
59
|
+
### Customize the native tools
|
|
60
|
+
|
|
61
|
+
Give a generated tool a title for your approval display with native `Tool` and `Toolkit` from
|
|
62
|
+
`effect/ai`: [assistant/src/toolkit.ts](../examples/assistant/src/toolkit.ts). The host reads the
|
|
63
|
+
title when printing approval requests in [agent.ts](../examples/assistant/src/agent.ts).
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const actions = ActionToolkit.make([tasks, identity]);
|
|
67
|
+
const toolkit = Toolkit.merge(
|
|
68
|
+
actions.toolkit,
|
|
69
|
+
Toolkit.make(actions.toolkit.tools.addTask.annotate(Tool.Title, "Add a task")),
|
|
70
|
+
);
|
|
71
|
+
const layer = actions.layer;
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `Toolkit.merge` keeps the later definition on a name collision. Put the customized one last;
|
|
75
|
+
keep names distinct when adding unrelated tools.
|
|
76
|
+
- `annotate` and `annotateMerge` return a new definition preserving the generated tool's handler
|
|
77
|
+
identity and approval rule. Keep the original `layer`. Recreating it with `Tool.make` under
|
|
78
|
+
the same name does not preserve that binding.
|
|
79
|
+
- To add other native tools, use `Toolkit.merge(toolkit, others)` and provide both handler layers
|
|
80
|
+
with `Layer.mergeAll(layer, othersHandlers)`.
|
|
81
|
+
|
|
60
82
|
### Call a tool by hand
|
|
61
83
|
|
|
62
84
|
`yield* toolkit` gives `tools.handle(name, arguments)`, which takes the JSON a model sends, an
|
|
@@ -116,4 +138,5 @@ chat
|
|
|
116
138
|
- **Continue on `toolCalls`, never on `toolResults`.** The answering turn's response holds the approved call's result, no tool call, and the model's reply to it: a loop that continues while a response has tool results asks the model again and loses that reply.
|
|
117
139
|
- **If a turn fails or is interrupted once an approved call has started**, the model failing or the client going away, the call may have run, and may run again. A failed `generateText` records nothing of its turn, so the same answer sent again runs the call again. A failed `streamText` records its prompt and the parts it streamed: a call that returned has its result recorded, but one that failed or was cut off while running leaves its answer without a result, which the next turn acts on unasked; `openApprovals` leaves that request out, as another answer would run the call twice. End the conversation where a turn does not succeed, or record an answer in your own store before its turn, and say in the error that an approved call may have run. Run one turn of a conversation at a time, and look it up again once the lock is held, so a turn that waited does not run on a conversation just ended: [kitchen-sink/src/assistant/conversations.ts](../examples/kitchen-sink/src/assistant/conversations.ts) does both.
|
|
118
140
|
- **Show the person what is asked.** A request names its call by `toolCallId`; that call, in `response.toolCalls`, holds the model's `params`, typed `unknown`. Show them as JSON, as [assistant/src/agent.ts](../examples/assistant/src/agent.ts) does, which joins the two; decode them with `Schema.toCodecJson(AddTask.input)`, the tool's parameters, only where you read a field.
|
|
141
|
+
- **If repeating a write would create an unwanted additional effect, retries need application-owned deduplication.** Keep the same operation key across attempts, as in [notes-service's creation example](../examples/notes-service/README.md#retry-a-creation). Approval history alone does not make the mutation and its result record atomic.
|
|
119
142
|
- **Only the person asked answers.** A `tool-approval-response` whose `approvalId` matches a request in the history without a result is acted on, as the turn's caller, an answered one included: take an `approvalId` only from that caller, among those `openApprovals` lists, and keep the `Chat` where its owner alone reaches it.
|
|
@@ -64,7 +64,7 @@ Tasks: [Authentication.md](Authentication.md),
|
|
|
64
64
|
too ([ActionMcp.md](ActionMcp.md#protect-tools)).
|
|
65
65
|
- A verifier's declared error arrives as 422: the error states no `httpApiStatus`. Give it the
|
|
66
66
|
status it means, such as `{ httpApiStatus: 503 }`.
|
|
67
|
-
- `ActionMcp.client` fails with an `McpCallError`, not a typed error, when the verifier fails
|
|
67
|
+
- `ActionMcp.client` fails with an `McpCallError` whose `reason` is a `StatusError`, not a typed error, when the verifier fails
|
|
68
68
|
with an error its descriptor declares: over MCP that failure is the endpoint's HTTP answer,
|
|
69
69
|
before any tool runs. Pass the endpoint's descriptor as the client's `authentication`.
|
|
70
70
|
- A route's `Cache-Control` is replaced by `no-store`: only a route's own answer keeps its
|
package/docs/CONTEXT.md
CHANGED
|
@@ -38,7 +38,8 @@ page.
|
|
|
38
38
|
|
|
39
39
|
The parts of the layout every example uses: [examples/README.md](../examples/README.md#how-they-are-laid-out).
|
|
40
40
|
|
|
41
|
-
- **Domain**: one subject of the application, such as notes or identity, and the folder holding everything about it. A folder is never a mechanism, such as `contracts/` or `handlers
|
|
41
|
+
- **Domain**: one subject of the application, such as notes or identity, and the folder holding everything about it. A folder is never a mechanism, such as `contracts/` or `handlers/`, but **Platform**.
|
|
42
|
+
- **Platform**: `platform/`, the one folder named for a mechanism: the adapters, each a service's Layer over a technology, such as a file or an upstream HTTP API. A domain imports none of them; an entrypoint provides them.
|
|
42
43
|
- **Contract file**: the PascalCase file of a domain, such as `notes/Notes.ts`: its schemas, errors and actions, and the identity and descriptor it declares. It imports no server code.
|
|
43
44
|
- **Routes**: `routes.ts`, the host's surfaces as one Layer. A route, singular, is one HTTP route.
|
|
44
45
|
- **Entrypoint**: a file that runs the application one way, such as `server.ts`, `stdio.ts` or `cli.ts`: the **composition root**, the one place that chooses the services, the platform and, on a local surface, the caller.
|
package/docs/README.md
CHANGED
|
@@ -10,10 +10,10 @@ same schemas, handler and authorization.
|
|
|
10
10
|
**Arriving with an error?** Search the `*-failures.md` pages for a fragment of its message
|
|
11
11
|
first (step 5), before the examples and the pages.
|
|
12
12
|
|
|
13
|
-
1. **Open one page per tool call, with your file-reading tool.**
|
|
14
|
-
in one call, however many commands it runs, can be cut by its output
|
|
15
|
-
with one that understates what is lost. If an output says it was
|
|
16
|
-
again alone.
|
|
13
|
+
1. **Open one page per tool call, with your file-reading tool.** Pages are kept small to reduce
|
|
14
|
+
truncation. Several in one call, however many commands it runs, can be cut by its output
|
|
15
|
+
limit, with no mark or with one that understates what is lost. If an output says it was
|
|
16
|
+
truncated, read that page again alone.
|
|
17
17
|
2. **Start from the example closest to what you are building** ([Examples](#examples)). Its
|
|
18
18
|
README has a layout tree and a "Where to look" table naming the file for each thing. Copy its
|
|
19
19
|
layout and idioms: the examples are the reference for how code is written. An example shows
|
package/docs/Testing.md
CHANGED
|
@@ -126,7 +126,9 @@ the endpoint serves, such as `implementation.actions`. Its failures:
|
|
|
126
126
|
[ActionMcp-failures.md](ActionMcp-failures.md).
|
|
127
127
|
|
|
128
128
|
A method fails with the action's declared errors and the built-in ones as decoded values, and with
|
|
129
|
-
`McpCallError` for any other answer
|
|
129
|
+
`McpCallError` for any other answer: its `reason` says which, `StatusError`, `ProtocolError`,
|
|
130
|
+
`NoReply`, `UndeclaredError` or `NoStructuredContent`, with what the endpoint sent, so a test
|
|
131
|
+
asserts on `reason` and `Effect.catchReason("McpCallError", "StatusError", ...)` recovers one;
|
|
130
132
|
structured content that does not decode as the success is a `SchemaError`, and a request that
|
|
131
133
|
fails Effect's `HttpClientError`. Only a tool's result carries the action's own errors: an HTTP
|
|
132
134
|
answer other than 200 decodes as a refusal, or, given the endpoint's `authentication`, as an error
|
package/examples/README.md
CHANGED
|
@@ -38,22 +38,30 @@ the parts of it that it needs:
|
|
|
38
38
|
├── src/
|
|
39
39
|
│ ├── <domain>/
|
|
40
40
|
│ │ ├── <Domain>.ts the contract: schemas, errors, actions
|
|
41
|
-
│ │ ├── <service>.ts a service and
|
|
41
|
+
│ │ ├── <service>.ts a service, its constructor and any in-memory Layer
|
|
42
42
|
│ │ └── handlers.ts the implementation: contract to service
|
|
43
|
+
│ ├── platform/ a service's Layer over a database, a file or an upstream API
|
|
43
44
|
│ ├── Api.ts, Rpc.ts the bindings, where a surface has one
|
|
44
45
|
│ ├── routes.ts the host's remote surfaces as Layers, where it serves any
|
|
45
46
|
│ └── server.ts … one entrypoint per way of running it
|
|
46
47
|
└── test/
|
|
47
48
|
├── <domain>/ what the domain does
|
|
49
|
+
├── platform/ an adapter, against a stand-in for what it reaches
|
|
48
50
|
└── routes.test.ts what the surfaces add, called in memory
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
- **Folders are domains, not mechanisms.** Everything about notes is in `notes/`; there is no
|
|
52
|
-
`contracts/`, `handlers/` or `tools/` folder.
|
|
54
|
+
`contracts/`, `handlers/` or `tools/` folder. `platform/`, the adapters, is the one exception.
|
|
53
55
|
- **The PascalCase file in a domain is its contract.** It imports no server code, so a browser
|
|
54
56
|
or a CLI can import it. Lowercase files beside it run on the server, each named for its role
|
|
55
57
|
(`handlers.ts`, `store.ts`), never for the domain: `notes.ts` beside `Notes.ts` is one file on
|
|
56
58
|
a disk that ignores case.
|
|
59
|
+
- **Technology stays out of domains.** A domain's service declares what its handlers need; a
|
|
60
|
+
Layer that reaches a file system, a database or another service's API is an adapter, in
|
|
61
|
+
`platform/`, named for what it reaches (`tasks-file.ts`, `print-farm.ts`), and turns that
|
|
62
|
+
technology's failures into the domain's declared errors, or into defects where no caller can
|
|
63
|
+
act on them. A Layer in memory, which reaches
|
|
64
|
+
nothing, stays beside its service.
|
|
57
65
|
- **An entrypoint is where implementations are chosen**: which store, which platform, and on a
|
|
58
66
|
local surface which caller. Nothing below it knows what it got, so a test chooses its own.
|
|
59
67
|
- **Tests mirror the source.** A domain's tests sit in `test/<domain>/`, most calling its
|
|
@@ -38,7 +38,7 @@ replace all three before relying on any of it.
|
|
|
38
38
|
src/
|
|
39
39
|
├── tasks/
|
|
40
40
|
│ ├── Tasks.ts contract: schemas, an error, four actions
|
|
41
|
-
│ ├── store.ts TaskStore
|
|
41
|
+
│ ├── store.ts TaskStore, its constructor and its memory Layer
|
|
42
42
|
│ └── handlers.ts Action.implement: contract to store
|
|
43
43
|
├── identity/
|
|
44
44
|
│ ├── Identity.ts contract: CurrentCaller, a Member or the Operator; whoAmI
|
|
@@ -46,13 +46,16 @@ src/
|
|
|
46
46
|
│ ├── authorize.ts the one authorization rule, built from Permissions
|
|
47
47
|
│ ├── members.ts the demo team
|
|
48
48
|
│ └── handlers.ts
|
|
49
|
+
├── platform/
|
|
50
|
+
│ └── tasks-file.ts DEMO ONLY: TaskStore's Layer over a JSON file
|
|
49
51
|
├── toolkit.ts the implementations as a Toolkit, and which calls need approval
|
|
50
52
|
├── commands.ts the implementations as commands, and each command's syntax
|
|
51
53
|
├── scripted.ts a model that needs no API key
|
|
52
54
|
├── agent.ts entrypoint: a model calling the tools, as a member
|
|
53
55
|
└── cli.ts entrypoint: the commands, as the operator
|
|
54
56
|
test/
|
|
55
|
-
├── tasks/ the implementation in process, with Action.client
|
|
57
|
+
├── tasks/ the implementation in process, with Action.client
|
|
58
|
+
├── platform/ the file store, on a file of the test's own
|
|
56
59
|
├── identity/ the authorizer: a caller per call, the permission store once
|
|
57
60
|
├── toolkit.test.ts a tool call, approvals, an approved call still authorized
|
|
58
61
|
└── commands.test.ts the commands run in memory: syntax and output
|
|
@@ -72,21 +75,22 @@ test/
|
|
|
72
75
|
|
|
73
76
|
## Where to look
|
|
74
77
|
|
|
75
|
-
| To see | Read
|
|
76
|
-
| -------------------------------------------------------------------- |
|
|
77
|
-
| Implementations as a native Effect AI `Toolkit` | `src/toolkit.ts`
|
|
78
|
-
|
|
|
79
|
-
|
|
|
80
|
-
|
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
|
|
|
84
|
-
|
|
|
85
|
-
|
|
|
86
|
-
| An
|
|
87
|
-
|
|
|
88
|
-
|
|
|
89
|
-
|
|
|
90
|
-
| Testing
|
|
91
|
-
| Testing a
|
|
92
|
-
| Testing a
|
|
78
|
+
| To see | Read |
|
|
79
|
+
| -------------------------------------------------------------------- | -------------------------------------------------- |
|
|
80
|
+
| Implementations as a native Effect AI `Toolkit` | `src/toolkit.ts` |
|
|
81
|
+
| Native tool titles in the approval display | `src/toolkit.ts`, `src/agent.ts` |
|
|
82
|
+
| One approval rule reading the call and its caller | `src/toolkit.ts` |
|
|
83
|
+
| A turn run as a member, and an approval answered through a `Chat` | `src/agent.ts` |
|
|
84
|
+
| Where a real `LanguageModel` plugs in | `src/agent.ts`, `src/scripted.ts` |
|
|
85
|
+
| Local commands with `positional`, `aliases`, `render`, `stdin` | `src/commands.ts` |
|
|
86
|
+
| Services provided on the command, and the host naming the caller | `src/cli.ts` |
|
|
87
|
+
| A command's result alone on stdout, its handler's logs on stderr | `src/cli.ts`, `src/platform/tasks-file.ts` |
|
|
88
|
+
| `logToStderr` moving a defect's report from stdout to stderr | `src/cli.ts` |
|
|
89
|
+
| An identity only the host builds: the trusted operator | `src/identity/Identity.ts` |
|
|
90
|
+
| An authorizer built once from a service, reading the caller per call | `src/identity/authorize.ts` |
|
|
91
|
+
| One service with two Layers, chosen by the entrypoint | `src/tasks/store.ts`, `src/platform/tasks-file.ts` |
|
|
92
|
+
| Changes serialized, since a turn's tool calls run concurrently | `src/tasks/store.ts` |
|
|
93
|
+
| Testing handlers and authorization, each caller around its calls | `test/tasks/tasks.test.ts` |
|
|
94
|
+
| Testing a Layer over a temporary file, its path set through config | `test/platform/tasks-file.test.ts` |
|
|
95
|
+
| Testing a tool call, an approval required, an approval answered | `test/toolkit.test.ts` |
|
|
96
|
+
| Testing a command's syntax and output without a process | `test/commands.test.ts` |
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
2
|
-
import { Console, Effect, Layer } from "effect";
|
|
3
|
-
import { Chat, type LanguageModel, type Prompt } from "effect/ai";
|
|
2
|
+
import { Console, Context, Effect, Layer } from "effect";
|
|
3
|
+
import { Chat, type LanguageModel, type Prompt, Tool } from "effect/ai";
|
|
4
4
|
import type { Member } from "./identity/Identity.js";
|
|
5
5
|
import { members } from "./identity/members.js";
|
|
6
6
|
import { Permissions } from "./identity/permissions.js";
|
|
7
7
|
import { scripted } from "./scripted.js";
|
|
8
|
-
import {
|
|
8
|
+
import { tasksFile } from "./platform/tasks-file.js";
|
|
9
9
|
import { actingFor, layer, toolkit } from "./toolkit.js";
|
|
10
10
|
|
|
11
11
|
// Entrypoint: `node --import tsx examples/assistant/src/agent.ts`.
|
|
@@ -28,9 +28,13 @@ const report = (member: Member) => (response: Response) => {
|
|
|
28
28
|
...response.toolResults.map(
|
|
29
29
|
({ name, encodedResult }) => ` ${name} -> ${JSON.stringify(encodedResult)}`,
|
|
30
30
|
),
|
|
31
|
-
...response.toolCalls.flatMap(({ id, name, params }) =>
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
...response.toolCalls.flatMap(({ id, name, params }) => {
|
|
32
|
+
const title = Context.getOrElse(toolkit.tools[name].annotations, Tool.Title, () => name);
|
|
33
|
+
|
|
34
|
+
return waiting.includes(id)
|
|
35
|
+
? [` ${title} ${JSON.stringify(params)} waits for approval`]
|
|
36
|
+
: [];
|
|
37
|
+
}),
|
|
34
38
|
...(response.text === "" ? [] : [` "${response.text}"`]),
|
|
35
39
|
].join("\n"),
|
|
36
40
|
);
|
|
@@ -86,7 +90,7 @@ conversations.pipe(
|
|
|
86
90
|
// that provider's client, in place of the scripted one. Nothing below this line changes.
|
|
87
91
|
Effect.provide(model),
|
|
88
92
|
// Startup services only: where tasks and permissions are kept. Never a caller.
|
|
89
|
-
Effect.provide(layer.pipe(Layer.provide([
|
|
93
|
+
Effect.provide(layer.pipe(Layer.provide([tasksFile, Permissions.layerMemory]))),
|
|
90
94
|
Effect.provide(NodeServices.layer),
|
|
91
95
|
NodeRuntime.runMain,
|
|
92
96
|
);
|
|
@@ -6,7 +6,7 @@ import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
|
6
6
|
import { commands } from "./commands.js";
|
|
7
7
|
import { CurrentCaller, type Operator } from "./identity/Identity.js";
|
|
8
8
|
import { Permissions } from "./identity/permissions.js";
|
|
9
|
-
import {
|
|
9
|
+
import { tasksFile } from "./platform/tasks-file.js";
|
|
10
10
|
|
|
11
11
|
// Entrypoint: `node --import tsx examples/assistant/src/cli.ts list-tasks`.
|
|
12
12
|
// The operator's own tool. No credential arrives at a terminal, so this host names the caller
|
|
@@ -17,7 +17,7 @@ const operator: Operator = { kind: "operator", id: userInfo().username };
|
|
|
17
17
|
const assistant = commands.pipe(
|
|
18
18
|
// Services go on the command, so they are built when an action runs: `--help` and a
|
|
19
19
|
// mistyped flag never open the tasks file.
|
|
20
|
-
Command.provide(Layer.mergeAll(
|
|
20
|
+
Command.provide(Layer.mergeAll(tasksFile, Permissions.layerMemory)),
|
|
21
21
|
Command.provideSync(CurrentCaller, operator),
|
|
22
22
|
);
|
|
23
23
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Config, Effect, FileSystem, Layer, Schema } from "effect";
|
|
4
|
+
import { TaskStore } from "../tasks/store.js";
|
|
5
|
+
import { Task } from "../tasks/Tasks.js";
|
|
6
|
+
|
|
7
|
+
const TasksFile = Schema.fromJsonString(Schema.Array(Task));
|
|
8
|
+
|
|
9
|
+
// DEMO ONLY: one JSON file, rewritten on every change. Changes are serialized within a
|
|
10
|
+
// process, by the store's lock, but nothing locks the file across processes: the agent and a
|
|
11
|
+
// command writing at once lose a task. A real application uses a database. The agent and the
|
|
12
|
+
// commands are separate processes: this file is what lets one see what the other wrote.
|
|
13
|
+
export const tasksFile = Layer.effect(
|
|
14
|
+
TaskStore,
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const files = yield* FileSystem.FileSystem;
|
|
17
|
+
|
|
18
|
+
const path = yield* Config.String("ASSISTANT_TASKS").pipe(
|
|
19
|
+
Config.withDefault(join(tmpdir(), "assistant-tasks.json")),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const load = files.readFileString(path).pipe(
|
|
23
|
+
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed("[]")),
|
|
24
|
+
Effect.flatMap(Schema.decodeEffect(TasksFile)),
|
|
25
|
+
Effect.orDie,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const save = (tasks: ReadonlyArray<Task>) =>
|
|
29
|
+
Schema.encodeEffect(TasksFile)(tasks).pipe(
|
|
30
|
+
Effect.flatMap((json) => files.writeFileString(path, json)),
|
|
31
|
+
// A log, not a result: a command sends its handler's logs to stderr.
|
|
32
|
+
Effect.tap(() => Effect.log(`Saved ${tasks.length} tasks to ${path}.`)),
|
|
33
|
+
Effect.orDie,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return TaskStore.make(load, save);
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { Config, Context, Effect, FileSystem, Layer, Ref, Schema, Semaphore } from "effect";
|
|
1
|
+
import { Context, Effect, Layer, Ref, Semaphore } from "effect";
|
|
4
2
|
import { Task, TaskNotFound } from "./Tasks.js";
|
|
5
3
|
|
|
6
4
|
type TaskList = ReadonlyArray<Task>;
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// Every method reads the whole list, and a change writes it back: the two layers below differ
|
|
11
|
-
// only in where the list is kept.
|
|
6
|
+
// Every method reads the whole list, and a change writes it back: a layer says only where the
|
|
7
|
+
// list is kept, in memory below or in a file (src/platform/tasks-file.ts).
|
|
12
8
|
const make = (load: Effect.Effect<TaskList>, save: (tasks: TaskList) => Effect.Effect<void>) => {
|
|
13
9
|
// A model's tool calls in one turn run concurrently. Two changes reading the list before
|
|
14
10
|
// either writes it would lose one, so a change holds this lock from its read to its write.
|
|
@@ -55,6 +51,9 @@ export class TaskStore extends Context.Service<
|
|
|
55
51
|
readonly annotate: (id: string, notes: string) => Effect.Effect<Task, TaskNotFound>;
|
|
56
52
|
}
|
|
57
53
|
>()("assistant/TaskStore") {
|
|
54
|
+
/** The store over a list kept by `load` and `save`, each change serialized. */
|
|
55
|
+
static readonly make = make;
|
|
56
|
+
|
|
58
57
|
/** Memory, lost with the process: what a test provides. */
|
|
59
58
|
static readonly layerMemory = Layer.effect(
|
|
60
59
|
TaskStore,
|
|
@@ -62,35 +61,4 @@ export class TaskStore extends Context.Service<
|
|
|
62
61
|
make(Ref.get(tasks), (next) => Ref.set(tasks, next)),
|
|
63
62
|
),
|
|
64
63
|
);
|
|
65
|
-
|
|
66
|
-
// DEMO ONLY: one JSON file, rewritten on every change. Changes are serialized within a
|
|
67
|
-
// process, by the lock above, but nothing locks the file across processes: the agent and a
|
|
68
|
-
// command writing at once lose a task. A real application uses a database. The agent and the
|
|
69
|
-
// commands are separate processes: this file is what lets one see what the other wrote.
|
|
70
|
-
static readonly layerFile = Layer.effect(
|
|
71
|
-
TaskStore,
|
|
72
|
-
Effect.gen(function* () {
|
|
73
|
-
const files = yield* FileSystem.FileSystem;
|
|
74
|
-
|
|
75
|
-
const path = yield* Config.String("ASSISTANT_TASKS").pipe(
|
|
76
|
-
Config.withDefault(join(tmpdir(), "assistant-tasks.json")),
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const load = files.readFileString(path).pipe(
|
|
80
|
-
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed("[]")),
|
|
81
|
-
Effect.flatMap(Schema.decodeEffect(TasksFile)),
|
|
82
|
-
Effect.orDie,
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
const save = (tasks: TaskList) =>
|
|
86
|
-
Schema.encodeEffect(TasksFile)(tasks).pipe(
|
|
87
|
-
Effect.flatMap((json) => files.writeFileString(path, json)),
|
|
88
|
-
// A log, not a result: a command sends its handler's logs to stderr.
|
|
89
|
-
Effect.tap(() => Effect.log(`Saved ${tasks.length} tasks to ${path}.`)),
|
|
90
|
-
Effect.orDie,
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
return make(load, save);
|
|
94
|
-
}),
|
|
95
|
-
);
|
|
96
64
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, Option } from "effect";
|
|
2
|
+
import { Tool, Toolkit } from "effect/ai";
|
|
2
3
|
import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
3
4
|
import { identity } from "./identity/handlers.js";
|
|
4
5
|
import { CurrentCaller, type Member } from "./identity/Identity.js";
|
|
@@ -7,7 +8,7 @@ import { tasks } from "./tasks/handlers.js";
|
|
|
7
8
|
// The implementations as a native Effect AI Toolkit: a tool per action, run in this process.
|
|
8
9
|
// `toolkit` goes to `LanguageModel.generateText`; `layer` holds the handlers, and requires the
|
|
9
10
|
// startup services their builders read. Neither holds a caller.
|
|
10
|
-
|
|
11
|
+
const actions = ActionToolkit.make([tasks, identity], {
|
|
11
12
|
// One rule over every call a model makes, asked before the call runs. A read runs at once. A
|
|
12
13
|
// write waits for a person to approve it, except a task the caller adds to their own list:
|
|
13
14
|
// `call.name` narrows `call.input` to that action's. Without a caller, it asks.
|
|
@@ -23,6 +24,15 @@ export const { toolkit, layer } = ActionToolkit.make([tasks, identity], {
|
|
|
23
24
|
),
|
|
24
25
|
});
|
|
25
26
|
|
|
27
|
+
// A title for this assistant's approval display. The model still calls the tool `addTask`.
|
|
28
|
+
// The later definition wins; annotating the generated tool keeps its handler and approval rule.
|
|
29
|
+
export const toolkit = Toolkit.merge(
|
|
30
|
+
actions.toolkit,
|
|
31
|
+
Toolkit.make(actions.toolkit.tools.addTask.annotate(Tool.Title, "Add a task")),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export const layer = actions.layer;
|
|
35
|
+
|
|
26
36
|
// Who a turn runs as is provided around that turn, never to `layer`: one toolkit serves every
|
|
27
37
|
// member. This is how this example names a model's caller, and it takes a `Member`. The tools
|
|
28
38
|
// themselves accept any `CurrentCaller`, so keeping the operator away from a model is this
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NodeServices } from "@effect/platform-node";
|
|
2
2
|
import { describe, expect, it } from "@effect/vitest";
|
|
3
3
|
import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect";
|
|
4
|
+
import { tasksFile } from "../../src/platform/tasks-file.js";
|
|
4
5
|
import { TaskStore } from "../../src/tasks/store.js";
|
|
5
6
|
|
|
6
7
|
// The file store, on a file of the test's own: `ASSISTANT_TASKS` is set through a
|
|
@@ -13,7 +14,7 @@ const fileStore = Effect.gen(function* () {
|
|
|
13
14
|
const tasks = path.join(directory, "tasks.json");
|
|
14
15
|
const config = ConfigProvider.fromUnknown({ ASSISTANT_TASKS: tasks });
|
|
15
16
|
|
|
16
|
-
return
|
|
17
|
+
return tasksFile.pipe(Layer.provide(ConfigProvider.layer(config)));
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
describe("the file store", () => {
|
|
@@ -3,6 +3,7 @@ import { Config, Effect, flow, Option } from "effect";
|
|
|
3
3
|
import { Command } from "effect/cli";
|
|
4
4
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
5
5
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
6
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
7
|
import { Http } from "./Api.js";
|
|
7
8
|
|
|
8
9
|
// Entrypoint: `node --import tsx examples/kitchen-sink/src/cli.ts list-issues`.
|
|
@@ -41,27 +42,24 @@ const tracker = ActionCli.remote(Http, {
|
|
|
41
42
|
`${attachment.name}: ${image.mimeType}, ${image.data.byteLength} bytes`,
|
|
42
43
|
},
|
|
43
44
|
},
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const url = yield* Config.String("TRACKER_URL").pipe(
|
|
53
|
-
Config.withDefault("http://127.0.0.1:3000"),
|
|
54
|
-
);
|
|
45
|
+
// The connection, read from `Config` when a command runs, so `--help` needs none of it. It is
|
|
46
|
+
// these commands' client, so it reaches their requests and no others. The token and the
|
|
47
|
+
// workspace are optional: `tracker get-status` is public and needs neither, and any other
|
|
48
|
+
// command without them gets the server's 401.
|
|
49
|
+
client: Effect.gen(function* () {
|
|
50
|
+
const baseUrl = yield* Config.String("TRACKER_URL").pipe(
|
|
51
|
+
Config.withDefault("http://127.0.0.1:3000"),
|
|
52
|
+
);
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
const token = yield* Config.option(Config.Redacted("TRACKER_TOKEN"));
|
|
55
|
+
const workspace = yield* Config.option(Config.String("TRACKER_WORKSPACE"));
|
|
58
56
|
|
|
59
|
-
|
|
57
|
+
const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
return yield* ActionHttp.client(Http, {
|
|
60
|
+
baseUrl,
|
|
61
|
+
transformClient: HttpClient.mapRequest(
|
|
63
62
|
flow(
|
|
64
|
-
HttpClientRequest.prependUrl(url),
|
|
65
63
|
Option.match(token, {
|
|
66
64
|
onNone: () => unchanged,
|
|
67
65
|
onSome: (value) => HttpClientRequest.bearerToken(value),
|
|
@@ -71,10 +69,10 @@ const tracker = ActionCli.remote(Http, {
|
|
|
71
69
|
onSome: (slug) => HttpClientRequest.setHeader("x-workspace", slug),
|
|
72
70
|
}),
|
|
73
71
|
),
|
|
74
|
-
)
|
|
75
|
-
})
|
|
76
|
-
),
|
|
77
|
-
);
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
78
76
|
|
|
79
77
|
// A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
|
|
80
78
|
// HTTP sends, such as `{"_tag":"IssueNotFound","id":"ACME-9"}`, and the command exits 1.
|
|
@@ -91,9 +91,11 @@ and no `name` or `arguments`.
|
|
|
91
91
|
src/
|
|
92
92
|
├── printers/
|
|
93
93
|
│ ├── Printers.ts contract: the printer, the one declared error, the five tools
|
|
94
|
-
│ ├── farm.ts PrintFarm, the upstream
|
|
95
|
-
│ ├── fake.ts DEMO ONLY: the farm's API answered in memory, as an HttpClient
|
|
94
|
+
│ ├── farm.ts PrintFarm, the upstream as a service: what the handlers call
|
|
96
95
|
│ └── handlers.ts Action.implement: tool to service
|
|
96
|
+
├── platform/
|
|
97
|
+
│ ├── print-farm.ts printFarm, PrintFarm's Layer over the farm's HTTP API
|
|
98
|
+
│ └── fake-farm.ts DEMO ONLY: the farm's API answered in memory, as an HttpClient
|
|
97
99
|
├── instructions.ts the server's name, version and its instructions for the model
|
|
98
100
|
├── routes.ts the HTTP surface: /mcp and /healthz
|
|
99
101
|
├── server.ts entrypoint: MCP over HTTP
|
|
@@ -103,13 +105,14 @@ test/
|
|
|
103
105
|
└── routes.test.ts over MCP in memory, with Testing.layer
|
|
104
106
|
```
|
|
105
107
|
|
|
106
|
-
- **The upstream is a service, built once.** `
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
- **The upstream is a service, built once.** `farm.ts` declares what the handlers need of the
|
|
109
|
+
farm, and `platform/print-farm.ts` is its Layer over the farm's HTTP API: it reads its URL and
|
|
110
|
+
credentials from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in
|
|
111
|
+
while it is built. The handlers get it already signed in and only delegate: `handlers.ts`
|
|
112
|
+
maps each tool onto a method of `PrintFarm`, which knows nothing of tools or HTTP, so a second
|
|
113
|
+
caller of the farm, a job or another surface, takes the service alone.
|
|
111
114
|
- **One declared error, mapped in one place.** A refusal, an unreachable farm, an answer of
|
|
112
|
-
another shape and one that never comes each leave `farm.ts` as a `PrintFarmError` with a
|
|
115
|
+
another shape and one that never comes each leave `platform/print-farm.ts` as a `PrintFarmError` with a
|
|
113
116
|
message a model can act on. No handler catches anything.
|
|
114
117
|
- **What a model sends is checked by the contract.** A printer id becomes part of the farm's
|
|
115
118
|
URL, so its schema admits only what is safe there, and anything else is refused before a
|
|
@@ -128,14 +131,14 @@ test/
|
|
|
128
131
|
| One declared error shared by every tool | `src/printers/Printers.ts` |
|
|
129
132
|
| An input a model cannot use to reach another URL | `src/printers/Printers.ts` |
|
|
130
133
|
| A tool answering an image beside other fields | `src/printers/Printers.ts` |
|
|
131
|
-
| An upstream client reading `Config`, signing in once | `src/
|
|
132
|
-
| An upstream's failures mapped to the declared error | `src/
|
|
134
|
+
| An upstream client reading `Config`, signing in once | `src/platform/print-farm.ts` |
|
|
135
|
+
| An upstream's failures mapped to the declared error | `src/platform/print-farm.ts` |
|
|
133
136
|
| A builder yielding a service, and handlers that only delegate | `src/printers/handlers.ts` |
|
|
134
137
|
| Server `instructions`, shared by both transports | `src/instructions.ts` |
|
|
135
138
|
| Allowed origins from `Config`, their CORS, and `/healthz` | `src/routes.ts` |
|
|
136
139
|
| A request body limit, and where an unauthenticated server binds | `src/server.ts` |
|
|
137
140
|
| A stdio server whose logs go to stderr | `src/stdio.ts` |
|
|
138
|
-
| A fake upstream provided as a Layer, to a test or an entrypoint | `src/
|
|
141
|
+
| A fake upstream provided as a Layer, to a test or an entrypoint | `src/platform/fake-farm.ts` |
|
|
139
142
|
| Testing tools and each upstream failure's message in process | `test/printers/printers.test.ts` |
|
|
140
143
|
| Testing listed hints, an image and the origin check in memory | `test/routes.test.ts` |
|
|
141
144
|
| Testing CORS for one configured origin and for several | `test/routes.test.ts` |
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { Effect, Layer, Option, Schema } from "effect";
|
|
3
3
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
|
-
import type { Printer } from "
|
|
4
|
+
import type { Printer } from "../printers/Printers.js";
|
|
5
5
|
|
|
6
6
|
// DEMO ONLY: the farm's HTTP API answered in memory, so the example and its tests run without
|
|
7
|
-
// a farm. It is an `HttpClient`, so `
|
|
7
|
+
// a farm. It is an `HttpClient`, so `printFarm` sends it the requests it would send over
|
|
8
8
|
// the network. A real application has no such file: its entrypoints provide a real client,
|
|
9
9
|
// `FetchHttpClient.layer` from `effect/http`.
|
|
10
10
|
|