@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.
Files changed (76) hide show
  1. package/README.md +3 -1
  2. package/dist/Action.d.mts +3 -5
  3. package/dist/Action.mjs +3 -1
  4. package/dist/ActionCli.d.mts +31 -42
  5. package/dist/ActionCli.mjs +6 -16
  6. package/dist/ActionHttp.d.mts +12 -8
  7. package/dist/ActionHttp.mjs +3 -1
  8. package/dist/ActionMcp.d.mts +1 -1
  9. package/dist/ActionMcp.mjs +4 -1
  10. package/dist/ActionRpc.d.mts +5 -3
  11. package/dist/ActionRpc.mjs +2 -1
  12. package/dist/ActionToolkit.d.mts +3 -2
  13. package/dist/ActionToolkit.mjs +3 -1
  14. package/dist/Authentication.d.mts +1 -1
  15. package/dist/Authentication.mjs +1 -1
  16. package/dist/authentication/provider.d.mts +2 -2
  17. package/dist/authentication/refusal.mjs +1 -1
  18. package/dist/cli/commands.d.mts +4 -3
  19. package/dist/cli/commands.mjs +6 -5
  20. package/dist/contract/ast.mjs +10 -0
  21. package/dist/contract/call.d.mts +1 -1
  22. package/dist/contract/caller.d.mts +9 -0
  23. package/dist/contract/caller.mjs +12 -0
  24. package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
  25. package/dist/contract/declared.mjs +42 -0
  26. package/dist/contract/hints.mjs +14 -0
  27. package/dist/contract/implementation.d.mts +4 -3
  28. package/dist/contract/implementation.mjs +4 -8
  29. package/dist/contract/names.mjs +25 -0
  30. package/dist/contract/status.mjs +18 -0
  31. package/dist/http/client.d.mts +19 -9
  32. package/dist/http/client.mjs +6 -1
  33. package/dist/mcp/client.d.mts +52 -12
  34. package/dist/mcp/client.mjs +62 -19
  35. package/dist/mcp/protocol.mjs +1 -1
  36. package/docs/Action.md +17 -3
  37. package/docs/ActionCli-failures.md +10 -9
  38. package/docs/ActionCli.md +22 -21
  39. package/docs/ActionHttp-failures.md +6 -0
  40. package/docs/ActionHttp.md +9 -4
  41. package/docs/ActionMcp.md +3 -3
  42. package/docs/ActionToolkit.md +25 -2
  43. package/docs/Authentication-failures.md +1 -1
  44. package/docs/CONTEXT.md +2 -1
  45. package/docs/README.md +4 -4
  46. package/docs/Testing.md +3 -1
  47. package/examples/README.md +10 -2
  48. package/examples/assistant/README.md +24 -20
  49. package/examples/assistant/src/agent.ts +11 -7
  50. package/examples/assistant/src/cli.ts +2 -2
  51. package/examples/assistant/src/platform/tasks-file.ts +38 -0
  52. package/examples/assistant/src/tasks/store.ts +6 -38
  53. package/examples/assistant/src/toolkit.ts +11 -1
  54. package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
  55. package/examples/kitchen-sink/src/cli.ts +19 -21
  56. package/examples/mcp-tools/README.md +14 -11
  57. package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
  58. package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
  59. package/examples/mcp-tools/src/printers/farm.ts +6 -122
  60. package/examples/mcp-tools/src/server.ts +4 -4
  61. package/examples/mcp-tools/src/stdio.ts +4 -4
  62. package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
  63. package/examples/mcp-tools/test/routes.test.ts +4 -4
  64. package/examples/notes-service/README.md +34 -2
  65. package/examples/notes-service/src/cli.ts +3 -9
  66. package/examples/notes-service/src/commands.ts +40 -36
  67. package/examples/notes-service/src/identity/authorize.ts +1 -0
  68. package/examples/notes-service/src/notes/Notes.ts +22 -5
  69. package/examples/notes-service/src/notes/handlers.ts +2 -2
  70. package/examples/notes-service/src/notes/store.ts +36 -6
  71. package/examples/notes-service/test/commands.test.ts +13 -19
  72. package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
  73. package/examples/notes-service/test/routes.test.ts +40 -1
  74. package/package.json +5 -5
  75. package/dist/_virtual/_rolldown/runtime.mjs +0 -13
  76. package/dist/contract/rules.mjs +0 -100
@@ -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([TaskStore.layerFile, Permissions.layerMemory]))),
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.** A page fits one read. Several
14
- in one call, however many commands it runs, can be cut by its output limit, with no mark or
15
- with one that understates what is lost. If an output says it was truncated, read that page
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, a result without its structured content included;
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
@@ -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 its Layer
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 and its Layers: memory, and a JSON file
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; the file store
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
- | One approval rule reading the call and its caller | `src/toolkit.ts` |
79
- | A turn run as a member, and an approval answered through a `Chat` | `src/agent.ts` |
80
- | Where a real `LanguageModel` plugs in | `src/agent.ts`, `src/scripted.ts` |
81
- | Local commands with `positional`, `aliases`, `render`, `stdin` | `src/commands.ts` |
82
- | Services provided on the command, and the host naming the caller | `src/cli.ts` |
83
- | A command's result alone on stdout, its handler's logs on stderr | `src/cli.ts`, `src/tasks/store.ts` |
84
- | `logToStderr` moving a defect's report from stdout to stderr | `src/cli.ts` |
85
- | An identity only the host builds: the trusted operator | `src/identity/Identity.ts` |
86
- | An authorizer built once from a service, reading the caller per call | `src/identity/authorize.ts` |
87
- | One service with two Layers, chosen by the entrypoint | `src/tasks/store.ts` |
88
- | Changes serialized, since a turn's tool calls run concurrently | `src/tasks/store.ts` |
89
- | Testing handlers and authorization, each caller around its calls | `test/tasks/tasks.test.ts` |
90
- | Testing a Layer over a temporary file, its path set through config | `test/tasks/store.test.ts` |
91
- | Testing a tool call, an approval required, an approval answered | `test/toolkit.test.ts` |
92
- | Testing a command's syntax and output without a process | `test/commands.test.ts` |
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 { TaskStore } from "./tasks/store.js";
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
- waiting.includes(id) ? [` ${name} ${JSON.stringify(params)} waits for approval`] : [],
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([TaskStore.layerFile, Permissions.layerMemory]))),
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 { TaskStore } from "./tasks/store.js";
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(TaskStore.layerFile, Permissions.layerMemory)),
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 { tmpdir } from "node:os";
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
- const TasksFile = Schema.fromJsonString(Schema.Array(Task));
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
- export const { toolkit, layer } = ActionToolkit.make([tasks, identity], {
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 TaskStore.layerFile.pipe(Layer.provide(ConfigProvider.layer(config)));
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
- }).pipe(
45
- // The connection is read from `Config` when a command runs, so `--help` needs none of it.
46
- // It is provided on this command, so it reaches these commands' requests and no others.
47
- // The token and the workspace are optional: `tracker get-status` is public and needs neither,
48
- // and any other command without them gets the server's 401.
49
- Command.provideEffect(
50
- HttpClient.HttpClient,
51
- Effect.gen(function* () {
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
- const token = yield* Config.option(Config.Redacted("TRACKER_TOKEN"));
57
- const workspace = yield* Config.option(Config.String("TRACKER_WORKSPACE"));
54
+ const token = yield* Config.option(Config.Redacted("TRACKER_TOKEN"));
55
+ const workspace = yield* Config.option(Config.String("TRACKER_WORKSPACE"));
58
56
 
59
- const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
57
+ const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
60
58
 
61
- return HttpClient.mapRequest(
62
- yield* HttpClient.HttpClient,
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 API as a service, and its Layer
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.** `PrintFarm.layer` reads its URL and credentials
107
- from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in while it is
108
- built. The handlers get it already signed in and only delegate: `handlers.ts` maps each tool
109
- onto a method of `farm.ts`, which knows nothing of tools, so a second caller of the farm, a
110
- job or another surface, takes the service alone.
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/printers/farm.ts` |
132
- | An upstream's failures mapped to the declared error | `src/printers/farm.ts` |
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/printers/fake.ts` |
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 "./Printers.js";
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 `PrintFarm.layer` sends it the requests it would send over
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