@gjermundgaraba/effect-actions 0.16.0 → 0.17.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 (67) hide show
  1. package/dist/Action.d.mts +3 -5
  2. package/dist/Action.mjs +3 -1
  3. package/dist/ActionCli.d.mts +2 -2
  4. package/dist/ActionCli.mjs +2 -1
  5. package/dist/ActionHttp.d.mts +3 -2
  6. package/dist/ActionHttp.mjs +3 -1
  7. package/dist/ActionMcp.d.mts +1 -1
  8. package/dist/ActionMcp.mjs +4 -1
  9. package/dist/ActionRpc.d.mts +5 -3
  10. package/dist/ActionRpc.mjs +2 -1
  11. package/dist/ActionToolkit.d.mts +3 -2
  12. package/dist/ActionToolkit.mjs +3 -1
  13. package/dist/Authentication.d.mts +1 -1
  14. package/dist/Authentication.mjs +1 -1
  15. package/dist/authentication/provider.d.mts +2 -2
  16. package/dist/authentication/refusal.mjs +1 -1
  17. package/dist/cli/commands.d.mts +3 -2
  18. package/dist/cli/commands.mjs +2 -1
  19. package/dist/contract/ast.mjs +10 -0
  20. package/dist/contract/call.d.mts +1 -1
  21. package/dist/contract/caller.d.mts +9 -0
  22. package/dist/contract/caller.mjs +12 -0
  23. package/dist/contract/{rules.d.mts → declared.d.mts} +6 -10
  24. package/dist/contract/declared.mjs +42 -0
  25. package/dist/contract/hints.mjs +14 -0
  26. package/dist/contract/implementation.d.mts +4 -3
  27. package/dist/contract/implementation.mjs +4 -8
  28. package/dist/contract/names.mjs +25 -0
  29. package/dist/contract/status.mjs +18 -0
  30. package/dist/http/client.d.mts +3 -2
  31. package/dist/mcp/client.d.mts +40 -7
  32. package/dist/mcp/client.mjs +60 -17
  33. package/dist/mcp/protocol.mjs +1 -1
  34. package/docs/Action.md +17 -3
  35. package/docs/ActionCli.md +1 -1
  36. package/docs/ActionMcp.md +2 -2
  37. package/docs/ActionToolkit.md +25 -2
  38. package/docs/Authentication-failures.md +1 -1
  39. package/docs/CONTEXT.md +2 -1
  40. package/docs/README.md +4 -4
  41. package/docs/Testing.md +3 -1
  42. package/examples/README.md +10 -2
  43. package/examples/assistant/README.md +24 -20
  44. package/examples/assistant/src/agent.ts +11 -7
  45. package/examples/assistant/src/cli.ts +2 -2
  46. package/examples/assistant/src/platform/tasks-file.ts +38 -0
  47. package/examples/assistant/src/tasks/store.ts +6 -38
  48. package/examples/assistant/src/toolkit.ts +11 -1
  49. package/examples/assistant/test/{tasks/store.test.ts → platform/tasks-file.test.ts} +2 -1
  50. package/examples/mcp-tools/README.md +14 -11
  51. package/examples/mcp-tools/src/{printers/fake.ts → platform/fake-farm.ts} +2 -2
  52. package/examples/mcp-tools/src/platform/print-farm.ts +118 -0
  53. package/examples/mcp-tools/src/printers/farm.ts +6 -122
  54. package/examples/mcp-tools/src/server.ts +4 -4
  55. package/examples/mcp-tools/src/stdio.ts +4 -4
  56. package/examples/mcp-tools/test/printers/printers.test.ts +3 -3
  57. package/examples/mcp-tools/test/routes.test.ts +4 -4
  58. package/examples/notes-service/README.md +32 -0
  59. package/examples/notes-service/src/identity/authorize.ts +1 -0
  60. package/examples/notes-service/src/notes/Notes.ts +22 -5
  61. package/examples/notes-service/src/notes/handlers.ts +2 -2
  62. package/examples/notes-service/src/notes/store.ts +36 -6
  63. package/examples/notes-service/test/notes/idempotency.test.ts +132 -0
  64. package/examples/notes-service/test/routes.test.ts +40 -1
  65. package/package.json +5 -5
  66. package/dist/_virtual/_rolldown/runtime.mjs +0 -13
  67. package/dist/contract/rules.mjs +0 -100
@@ -1,8 +1,10 @@
1
+ import { Anyone } from "../contract/caller.mjs";
1
2
  import { Refusal } from "../contract/errors.mjs";
2
- import { Anyone, assertOnce, projectedErrors } from "../contract/rules.mjs";
3
+ import { assertOnce } from "../contract/names.mjs";
4
+ import { projectedErrors } from "../contract/declared.mjs";
3
5
  import { checked } from "../contract/call.mjs";
4
6
  import { defaultPath, httpProtocol, liftOf, unliftedFrom } from "./protocol.mjs";
5
- import { Effect, Option, Predicate, Schema, identity } from "effect";
7
+ import { Effect, Match, Option, Predicate, Schema, identity } from "effect";
6
8
  import { McpSchema } from "effect/ai";
7
9
  import { HttpClient, HttpClientRequest } from "effect/http";
8
10
  import { Sse } from "effect/encoding";
@@ -42,14 +44,56 @@ const statelessRequest = (method, params) => {
42
44
  }
43
45
  };
44
46
  };
47
+ /** The endpoint answered with an HTTP status other than 200, and a body no declared error decodes. */
48
+ var StatusError = class extends Schema.TaggedError()("StatusError", {
49
+ status: Schema.Int,
50
+ body: Schema.String
51
+ }) {};
45
52
  /**
46
- * An answer a client method cannot read as the action's success or a declared error, such as
47
- * the native server's message for invalid arguments or an unknown tool, or a result without
48
- * its structured content. Its message holds the answer. Structured content that does not
49
- * decode as the success is a `SchemaError` instead, and a request that fails an
50
- * `HttpClientError`.
53
+ * The endpoint answered with a JSON-RPC error, such as the native server's for invalid arguments
54
+ * or an unknown tool.
51
55
  */
52
- var McpCallError = class extends Schema.TaggedError()("McpCallError", { message: Schema.String }) {};
56
+ var ProtocolError = class extends Schema.TaggedError()("ProtocolError", {
57
+ code: Schema.Finite,
58
+ message: Schema.String
59
+ }) {};
60
+ /** The endpoint answered 200 without a JSON-RPC reply to read, as JSON or as an event stream. */
61
+ var NoReply = class extends Schema.TaggedError()("NoReply", { body: Schema.String }) {};
62
+ /** The tool returned an error whose text decodes as none of the action's declared errors. */
63
+ var UndeclaredError = class extends Schema.TaggedError()("UndeclaredError", { text: Schema.String }) {};
64
+ /** The tool returned a result without the structured content its action's success needs. */
65
+ var NoStructuredContent = class extends Schema.TaggedError()("NoStructuredContent", { body: Schema.String }) {};
66
+ const Reason = Schema.Union([
67
+ StatusError,
68
+ ProtocolError,
69
+ NoReply,
70
+ UndeclaredError,
71
+ NoStructuredContent
72
+ ]);
73
+ const sentenceOf = Match.typeTags()({
74
+ StatusError: ({ status, body }) => `answered ${status}: ${body}`,
75
+ ProtocolError: ({ code, message }) => `failed with ${code}: ${message}`,
76
+ NoReply: ({ body }) => `had no reply: ${body}`,
77
+ UndeclaredError: ({ text }) => `returned an error: ${text}`,
78
+ NoStructuredContent: ({ body }) => `returned no structured content: ${body}`
79
+ });
80
+ /**
81
+ * An answer a client method cannot read as the action's success or a declared error: `reason`
82
+ * says which, with what the endpoint sent, so a caller branches on it with
83
+ * `Effect.catchReason("McpCallError", "StatusError", ...)`. `message` names the tool and the
84
+ * reason. Structured content that does not decode as the success is a `SchemaError` instead,
85
+ * and a request that fails an `HttpClientError`.
86
+ */
87
+ var McpCallError = class extends Schema.TaggedError()("McpCallError", {
88
+ tool: Schema.String,
89
+ reason: Reason,
90
+ message: Schema.String
91
+ }) {};
92
+ const callError = (tool, reason) => new McpCallError({
93
+ tool,
94
+ reason,
95
+ message: `MCP tools/call "${tool}" ${sentenceOf(reason)}`
96
+ });
53
97
  const textOf = (block) => block?.type === "text" ? block.text : void 0;
54
98
  const ToolCallReply = Schema.Union([Schema.Struct({ result: McpSchema.CallToolResult }), Schema.Struct({ error: Schema.Struct({
55
99
  code: Schema.Finite,
@@ -67,7 +111,6 @@ const replyOf = (response, text) => {
67
111
  const failWith = (errors, text, otherwise) => Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(Schema.Union(errors))))(text).pipe(Effect.mapError(() => otherwise), Effect.flatMap(Effect.fail));
68
112
  const callTool = (client, url, action, lift, refusedWith, input) => {
69
113
  const { name } = action;
70
- const other = (answer) => new McpCallError({ message: `MCP tools/call "${name}" ${answer}` });
71
114
  return Effect.gen(function* () {
72
115
  const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.input))(input);
73
116
  const response = yield* client.execute(request("tools/call", {
@@ -75,19 +118,19 @@ const callTool = (client, url, action, lift, refusedWith, input) => {
75
118
  arguments: encoded
76
119
  }, { url }));
77
120
  const text = yield* response.text;
78
- if (response.status !== 200) return yield* failWith(refusedWith, text, other(`answered ${response.status}: ${text}`));
121
+ if (response.status !== 200) return yield* failWith(refusedWith, text, callError(name, new StatusError({
122
+ status: response.status,
123
+ body: text
124
+ })));
79
125
  const reply = replyOf(response, text);
80
- if (Option.isNone(reply)) return yield* Effect.fail(other(`had no reply: ${text}`));
81
- if ("error" in reply.value) {
82
- const { code, message } = reply.value.error;
83
- return yield* Effect.fail(other(`failed with ${code}: ${message}`));
84
- }
126
+ if (Option.isNone(reply)) return yield* Effect.fail(callError(name, new NoReply({ body: text })));
127
+ if ("error" in reply.value) return yield* Effect.fail(callError(name, new ProtocolError(reply.value.error)));
85
128
  const { result } = reply.value;
86
129
  if (result.isError === true) {
87
130
  const error = textOf(result.content.find((block) => block.type === "text")) ?? "";
88
- return yield* failWith(projectedErrors(action), error, other(`returned an error: ${error}`));
131
+ return yield* failWith(projectedErrors(action), error, callError(name, new UndeclaredError({ text: error })));
89
132
  }
90
- if (result.structuredContent === void 0 && lift.rest !== void 0) return yield* Effect.fail(other(`returned no structured content: ${text}`));
133
+ if (result.structuredContent === void 0 && lift.rest !== void 0) return yield* Effect.fail(callError(name, new NoStructuredContent({ body: text })));
91
134
  return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(unliftedFrom(lift, result));
92
135
  });
93
136
  };
@@ -1,4 +1,4 @@
1
- import { unsuspended } from "../contract/rules.mjs";
1
+ import { unsuspended } from "../contract/ast.mjs";
2
2
  import { Media, isMarked, kindOf } from "../contract/media.mjs";
3
3
  import { Predicate, Schema, SchemaAST } from "effect";
4
4
  import { McpProtocol } from "effect/ai";
package/docs/Action.md CHANGED
@@ -158,11 +158,25 @@ and the whole builder above [notes-service/src/notes/handlers.ts](../examples/no
158
158
  - **Pass the builder to `implement` itself.** Through `.pipe(...)` or a wrapper such as
159
159
  `Effect.withSpan(builder, name)`, an `Effect.fn` handler it returns takes an `any` input. A
160
160
  builder written apart returns its record `satisfies Action.Handlers<typeof actions>`.
161
- - **Leave a handler's `Effect.fn` unnamed**: each handler already runs in a span named after its
162
- action, and its log lines carry `action.name` and `action.read_only`.
161
+ - **Leave a handler's `Effect.fn` unnamed** unless you need handler-only timing: each action
162
+ already has a span ([Observe calls](#observe-calls)).
163
163
  - **Calling the function you passed to `implement` skips authorization.** Call the
164
164
  implementation through [`Action.client`](Action-in-process.md#call-actions-in-process).
165
165
 
166
+ ## Observe calls
167
+
168
+ The span named after an action includes authorization, handler execution and call cleanup.
169
+ Its exit reflects the final dispatch outcome; the span and its logs carry `action.name` and
170
+ `action.read_only`.
171
+
172
+ Transport credential verification, input decoding and response encoding run outside this span.
173
+ A request rejected by verification or decoding has no action span; response encoding can fail
174
+ after the action span succeeds.
175
+
176
+ Add a named handler span to measure handler time alone. The named authorizer in
177
+ [notes-service/src/identity/authorize.ts](../examples/notes-service/src/identity/authorize.ts)
178
+ similarly measures authorization as a child of the action span.
179
+
166
180
  ## Authorize calls
167
181
 
168
182
  An implementation holding a protected action states `{ authorize }`: a function of the called
@@ -233,5 +247,5 @@ failure compiles. What each surface answers is in [guarantees.md](guarantees.md#
233
247
  or `Forbidden`. Passed on, it is this server's own refusal: a caller whose token is valid is
234
248
  told to sign in again. Map it to an error the action declares, or to a defect.
235
249
  - **Map that client's `HttpClientError` and `SchemaError` to one declared error a caller can
236
- act on** ([mcp-tools/src/printers/farm.ts](../examples/mcp-tools/src/printers/farm.ts)). As
250
+ act on** ([mcp-tools/src/platform/print-farm.ts](../examples/mcp-tools/src/platform/print-farm.ts)). As
237
251
  defects, a model reads only a generic text.
package/docs/ActionCli.md CHANGED
@@ -47,7 +47,7 @@ Pass implementations to `make`, and provide the services and the caller on the c
47
47
 
48
48
  ```ts
49
49
  const assistant = ActionCli.make([tasks, identity], { name: "assistant" }).pipe(
50
- Command.provide(Layer.mergeAll(TaskStore.layerFile, Permissions.layerMemory)),
50
+ Command.provide(Layer.mergeAll(tasksFile, Permissions.layerMemory)),
51
51
  Command.provideSync(CurrentCaller, operator),
52
52
  );
53
53
  ```
package/docs/ActionMcp.md CHANGED
@@ -21,7 +21,7 @@ Import `@gjermundgaraba/effect-actions/ActionMcp`.
21
21
  | `LayerHttpOptions` | Type of `layerHttp`'s options: `Options` and HTTP's own. |
22
22
  | `client(actions, options?)` | An Effect of a client calling each action's tool. |
23
23
  | `request(method, params?, options?)` | One MCP request, as the native `HttpClientRequest` to send. |
24
- | `McpCallError` | A client method's failure for an answer it cannot decode. |
24
+ | `McpCallError` | A client method's undecodable answer, by tagged `reason`. |
25
25
 
26
26
  `implementations` is one implementation or a list.
27
27
 
@@ -140,7 +140,7 @@ tool's read-only hint, and the contract's `mcp` option holds its `title`, its ot
140
140
  field's `description`, annotated before the check: after a custom one it is lost too.
141
141
  - A declared error reaches the model as an `isError` result whose text is the error's JSON. Write
142
142
  its `message` for the model, which acts on it:
143
- [mcp-tools/src/printers/farm.ts](../examples/mcp-tools/src/printers/farm.ts). Every other case:
143
+ [mcp-tools/src/platform/print-farm.ts](../examples/mcp-tools/src/platform/print-farm.ts). Every other case:
144
144
  [guarantees.md](guarantees.md#wire-behavior).
145
145
  - `actions` lists the tools, and only listed actions must suit MCP:
146
146
  [kitchen-sink/src/Actions.ts](../examples/kitchen-sink/src/Actions.ts). Leave out an action a
@@ -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", () => {