@gjermundgaraba/effect-actions 0.11.0 → 0.13.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 +102 -41
- package/dist/Action.d.mts +31 -117
- package/dist/Action.mjs +14 -53
- package/dist/ActionCli.d.mts +60 -28
- package/dist/ActionCli.mjs +54 -48
- package/dist/ActionHttp.d.mts +18 -67
- package/dist/ActionHttp.mjs +20 -70
- package/dist/ActionMcp.d.mts +23 -29
- package/dist/ActionMcp.mjs +56 -114
- package/dist/ActionRpc.d.mts +147 -0
- package/dist/ActionRpc.mjs +113 -0
- package/dist/ActionToolkit.d.mts +28 -9
- package/dist/ActionToolkit.mjs +31 -4
- package/dist/Authentication.d.mts +15 -15
- package/dist/Authentication.mjs +42 -96
- package/dist/Testing.d.mts +30 -23
- package/dist/Testing.mjs +27 -44
- package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
- package/dist/authentication/provider.mjs +12 -0
- package/dist/authentication/refusal.mjs +36 -0
- package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
- package/dist/cli/commands.mjs +302 -0
- package/dist/{internal → contract}/call.d.mts +2 -2
- package/dist/contract/call.mjs +10 -0
- package/dist/{internal → contract}/errors.d.mts +13 -10
- package/dist/{internal → contract}/errors.mjs +9 -14
- package/dist/{internal → contract}/implementation.d.mts +37 -21
- package/dist/{internal → contract}/implementation.mjs +18 -49
- package/dist/contract/media.d.mts +13 -0
- package/dist/contract/media.mjs +18 -0
- package/dist/contract/rules.d.mts +25 -0
- package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
- package/dist/{internal → http}/client.d.mts +7 -9
- package/dist/http/client.mjs +17 -0
- package/dist/http/declared.mjs +20 -0
- package/dist/http/schema-errors.mjs +9 -0
- package/dist/mcp/protocol.d.mts +18 -0
- package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
- package/dist/stdio/console.mjs +64 -0
- package/dist/{internal → testing}/memory.d.mts +1 -1
- package/dist/{internal → testing}/memory.mjs +5 -17
- package/dist/toolkit/tools.mjs +29 -0
- package/docs/Action-failures.md +47 -0
- package/docs/Action-in-process.md +93 -0
- package/docs/Action.md +211 -426
- package/docs/ActionCli-failures.md +104 -0
- package/docs/ActionCli.md +208 -293
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +205 -282
- package/docs/ActionMcp-failures.md +31 -0
- package/docs/ActionMcp.md +222 -271
- package/docs/ActionRpc-failures.md +21 -0
- package/docs/ActionRpc.md +179 -0
- package/docs/ActionToolkit-failures.md +15 -0
- package/docs/ActionToolkit.md +103 -119
- package/docs/Authentication-advanced.md +139 -0
- package/docs/Authentication-failures.md +99 -0
- package/docs/Authentication.md +143 -392
- package/docs/CONTEXT.md +40 -24
- package/docs/README.md +75 -35
- package/docs/Testing-failures.md +21 -0
- package/docs/Testing.md +193 -151
- package/docs/guarantees.md +35 -96
- package/docs/setup-failures.md +37 -0
- package/docs/setup.md +90 -92
- package/examples/README.md +68 -0
- package/examples/assistant/README.md +92 -0
- package/examples/assistant/src/agent.ts +92 -0
- package/examples/assistant/src/cli.ts +34 -0
- package/examples/assistant/src/commands.ts +25 -0
- package/examples/assistant/src/identity/Identity.ts +33 -0
- package/examples/assistant/src/identity/authorize.ts +24 -0
- package/examples/assistant/src/identity/handlers.ts +10 -0
- package/examples/assistant/src/identity/members.ts +9 -0
- package/examples/assistant/src/identity/permissions.ts +23 -0
- package/examples/assistant/src/scripted.ts +23 -0
- package/examples/assistant/src/tasks/Tasks.ts +63 -0
- package/examples/assistant/src/tasks/handlers.ts +25 -0
- package/examples/assistant/src/tasks/store.ts +96 -0
- package/examples/assistant/src/toolkit.ts +30 -0
- package/examples/assistant/test/commands.test.ts +57 -0
- package/examples/assistant/test/identity/identity.test.ts +43 -0
- package/examples/assistant/test/tasks/store.test.ts +48 -0
- package/examples/assistant/test/tasks/tasks.test.ts +75 -0
- package/examples/assistant/test/toolkit.test.ts +121 -0
- package/examples/kitchen-sink/README.md +318 -0
- package/examples/kitchen-sink/src/Actions.ts +19 -0
- package/examples/kitchen-sink/src/Api.ts +24 -0
- package/examples/kitchen-sink/src/Rpc.ts +12 -0
- package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
- package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
- package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
- package/examples/kitchen-sink/src/admin/grants.ts +22 -0
- package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
- package/examples/kitchen-sink/src/admin/keys.ts +8 -0
- package/examples/kitchen-sink/src/agent.ts +47 -0
- package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
- package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
- package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
- package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
- package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
- package/examples/kitchen-sink/src/attachments/download.ts +49 -0
- package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
- package/examples/kitchen-sink/src/attachments/store.ts +58 -0
- package/examples/kitchen-sink/src/browser.ts +33 -0
- package/examples/kitchen-sink/src/cli.ts +85 -0
- package/examples/kitchen-sink/src/config.ts +18 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
- package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
- package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
- package/examples/kitchen-sink/src/identity/directory.ts +52 -0
- package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
- package/examples/kitchen-sink/src/identity/members.ts +13 -0
- package/examples/kitchen-sink/src/identity/quota.ts +62 -0
- package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
- package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
- package/examples/kitchen-sink/src/issues/guide.ts +37 -0
- package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
- package/examples/kitchen-sink/src/issues/store.ts +96 -0
- package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
- package/examples/kitchen-sink/src/memory.ts +30 -0
- package/examples/kitchen-sink/src/ops.ts +42 -0
- package/examples/kitchen-sink/src/request-policy.ts +63 -0
- package/examples/kitchen-sink/src/routes.ts +120 -0
- package/examples/kitchen-sink/src/sdk.ts +46 -0
- package/examples/kitchen-sink/src/seed.ts +65 -0
- package/examples/kitchen-sink/src/server.ts +62 -0
- package/examples/kitchen-sink/src/stdio.ts +34 -0
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
- package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
- package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
- package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
- package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
- package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
- package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
- package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
- package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
- package/examples/kitchen-sink/test/routes.test.ts +123 -0
- package/examples/kitchen-sink/test/support/scripted.ts +40 -0
- package/examples/live-rpc/README.md +107 -0
- package/examples/live-rpc/src/Rpc.ts +8 -0
- package/examples/live-rpc/src/browser.ts +98 -0
- package/examples/live-rpc/src/chat/Chat.ts +77 -0
- package/examples/live-rpc/src/chat/authorize.ts +16 -0
- package/examples/live-rpc/src/chat/handlers.ts +50 -0
- package/examples/live-rpc/src/chat/room.ts +23 -0
- package/examples/live-rpc/src/chat/store.ts +54 -0
- package/examples/live-rpc/src/client.ts +37 -0
- package/examples/live-rpc/src/identity/Identity.ts +19 -0
- package/examples/live-rpc/src/identity/members.ts +8 -0
- package/examples/live-rpc/src/identity/verifier.ts +23 -0
- package/examples/live-rpc/src/routes.ts +22 -0
- package/examples/live-rpc/src/server.ts +43 -0
- package/examples/live-rpc/test/browser.test.ts +105 -0
- package/examples/live-rpc/test/chat/chat.test.ts +69 -0
- package/examples/live-rpc/test/routes.test.ts +99 -0
- package/examples/live-rpc/test/server.test.ts +75 -0
- package/examples/mcp-tools/README.md +141 -0
- package/examples/mcp-tools/src/instructions.ts +12 -0
- package/examples/mcp-tools/src/printers/Printers.ts +121 -0
- package/examples/mcp-tools/src/printers/fake.ts +115 -0
- package/examples/mcp-tools/src/printers/farm.ts +136 -0
- package/examples/mcp-tools/src/printers/handlers.ts +29 -0
- package/examples/mcp-tools/src/routes.ts +52 -0
- package/examples/mcp-tools/src/server.ts +31 -0
- package/examples/mcp-tools/src/stdio.ts +26 -0
- package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
- package/examples/mcp-tools/test/routes.test.ts +202 -0
- package/examples/notes-service/README.md +111 -0
- package/examples/notes-service/src/Api.ts +13 -0
- package/examples/notes-service/src/browser.ts +41 -0
- package/examples/notes-service/src/cli.ts +22 -0
- package/examples/notes-service/src/commands.ts +137 -0
- package/examples/notes-service/src/identity/Identity.ts +31 -0
- package/examples/notes-service/src/identity/authorize.ts +18 -0
- package/examples/notes-service/src/identity/handlers.ts +10 -0
- package/examples/notes-service/src/identity/users.ts +9 -0
- package/examples/notes-service/src/identity/verifier.ts +36 -0
- package/examples/notes-service/src/notes/Notes.ts +119 -0
- package/examples/notes-service/src/notes/handlers.ts +39 -0
- package/examples/notes-service/src/notes/store.ts +84 -0
- package/examples/notes-service/src/routes.ts +45 -0
- package/examples/notes-service/src/server.ts +26 -0
- package/examples/notes-service/src/status/Status.ts +10 -0
- package/examples/notes-service/src/status/handlers.ts +14 -0
- package/examples/notes-service/src/stdio.ts +25 -0
- package/examples/notes-service/test/commands.test.ts +288 -0
- package/examples/notes-service/test/notes/notes.test.ts +103 -0
- package/examples/notes-service/test/routes.test.ts +84 -0
- package/examples/quickstart/README.md +31 -0
- package/examples/quickstart/src/Api.ts +5 -0
- package/examples/quickstart/src/client.ts +13 -0
- package/examples/quickstart/src/greeting/Greeting.ts +12 -0
- package/examples/quickstart/src/greeting/handlers.ts +6 -0
- package/examples/quickstart/src/routes.ts +11 -0
- package/examples/quickstart/src/server.ts +16 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
- package/package.json +27 -9
- package/dist/internal/actions.d.mts +0 -9
- package/dist/internal/authentication.mjs +0 -11
- package/dist/internal/call.mjs +0 -12
- package/dist/internal/cli.mjs +0 -328
- package/dist/internal/client.mjs +0 -25
- package/dist/internal/console.mjs +0 -77
- package/dist/internal/declared.d.mts +0 -13
- package/dist/internal/declared.mjs +0 -34
- package/dist/internal/mcp.d.mts +0 -14
- package/dist/internal/media.d.mts +0 -8
- package/dist/internal/media.mjs +0 -26
- package/dist/internal/refusal.mjs +0 -67
- package/dist/internal/schema-errors.mjs +0 -20
- package/dist/internal/tools.mjs +0 -44
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# assistant
|
|
2
|
+
|
|
3
|
+
An application with no server: its actions run in the process that calls them. A team's task
|
|
4
|
+
list is worked on by a model, through tools, and by whoever runs the machine, through commands.
|
|
5
|
+
Both are the same implementations behind the same authorization. It opens no port.
|
|
6
|
+
|
|
7
|
+
Start here if a model calls your code in process (an agent, a chat bot, a job), or if you are
|
|
8
|
+
building a local command-line tool. For a service others call over the network, start from
|
|
9
|
+
[notes-service](../notes-service/).
|
|
10
|
+
|
|
11
|
+
## Run it
|
|
12
|
+
|
|
13
|
+
From the repository root, after `vp install` (from an installed package, see
|
|
14
|
+
[Run one](../README.md#run-one)):
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
node --import tsx examples/assistant/src/agent.ts # a scripted model: no API key
|
|
18
|
+
|
|
19
|
+
node --import tsx examples/assistant/src/cli.ts --help
|
|
20
|
+
node --import tsx examples/assistant/src/cli.ts list-tasks # what the model added
|
|
21
|
+
node --import tsx examples/assistant/src/cli.ts add-task --title "Pay the caterer" --assignee grace
|
|
22
|
+
node --import tsx examples/assistant/src/cli.ts complete-task 1
|
|
23
|
+
echo "Vegetarian for six." | node --import tsx examples/assistant/src/cli.ts annotate-task --id 3
|
|
24
|
+
node --import tsx examples/assistant/src/cli.ts list-tasks -a grace --json 2>/dev/null
|
|
25
|
+
|
|
26
|
+
vp test examples/assistant
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The agent and each command are separate processes. They share one JSON file, named by
|
|
30
|
+
`ASSISTANT_TASKS` and kept in the system's temporary directory by default. Running the agent
|
|
31
|
+
again adds the same tasks again: delete the file to start over. The model is scripted, the
|
|
32
|
+
members and their permissions are fixed tables, and nothing locks the file between processes:
|
|
33
|
+
replace all three before relying on any of it.
|
|
34
|
+
|
|
35
|
+
## Layout
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
src/
|
|
39
|
+
├── tasks/
|
|
40
|
+
│ ├── Tasks.ts contract: schemas, an error, four actions
|
|
41
|
+
│ ├── store.ts TaskStore and its Layers: memory, and a JSON file
|
|
42
|
+
│ └── handlers.ts Action.implement: contract to store
|
|
43
|
+
├── identity/
|
|
44
|
+
│ ├── Identity.ts contract: CurrentCaller, a Member or the Operator; whoAmI
|
|
45
|
+
│ ├── permissions.ts Permissions, the store the authorizer reads
|
|
46
|
+
│ ├── authorize.ts the one authorization rule, built from Permissions
|
|
47
|
+
│ ├── members.ts the demo team
|
|
48
|
+
│ └── handlers.ts
|
|
49
|
+
├── toolkit.ts the implementations as a Toolkit, and which calls need approval
|
|
50
|
+
├── commands.ts the implementations as commands, and each command's syntax
|
|
51
|
+
├── scripted.ts a model that needs no API key
|
|
52
|
+
├── agent.ts entrypoint: a model calling the tools, as a member
|
|
53
|
+
└── cli.ts entrypoint: the commands, as the operator
|
|
54
|
+
test/
|
|
55
|
+
├── tasks/ the implementation in process, with Action.client; the file store
|
|
56
|
+
├── identity/ the authorizer: a caller per call, the permission store once
|
|
57
|
+
├── toolkit.test.ts a tool call, approvals, an approved call still authorized
|
|
58
|
+
└── commands.test.ts the commands run in memory: syntax and output
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **Two surfaces, no transport.** `toolkit.ts` and `commands.ts` take the same implementations.
|
|
62
|
+
Neither holds a caller or a store: `agent.ts` and `cli.ts` provide those, and so does a test.
|
|
63
|
+
- **The caller comes from the host, per call.** Nothing here authenticates anyone. `agent.ts`
|
|
64
|
+
provides the member around each turn; `cli.ts` provides the operator on the command. A caller
|
|
65
|
+
is never given to the toolkit's `layer`, which is built once for every member.
|
|
66
|
+
- **The operator is a type, not a flag.** `Member | Operator` is what an action may be called
|
|
67
|
+
as. Only `cli.ts` builds an `Operator`, and the demo team is typed as members, so no sign-in
|
|
68
|
+
produces one. A model's caller is named through `actingFor`, which takes a `Member`. The tools
|
|
69
|
+
themselves accept either: keeping the operator away from a model is the host's doing.
|
|
70
|
+
- **Approval is not authorization.** `needsApproval` decides which of a model's calls wait for a
|
|
71
|
+
person. `authorize` decides who may call at all, and runs for an approved call too.
|
|
72
|
+
|
|
73
|
+
## Where to look
|
|
74
|
+
|
|
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` |
|
|
@@ -0,0 +1,92 @@
|
|
|
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";
|
|
4
|
+
import type { Member } from "./identity/Identity.js";
|
|
5
|
+
import { members } from "./identity/members.js";
|
|
6
|
+
import { Permissions } from "./identity/permissions.js";
|
|
7
|
+
import { scripted } from "./scripted.js";
|
|
8
|
+
import { TaskStore } from "./tasks/store.js";
|
|
9
|
+
import { actingFor, layer, toolkit } from "./toolkit.js";
|
|
10
|
+
|
|
11
|
+
// Entrypoint: `node --import tsx examples/assistant/src/agent.ts`.
|
|
12
|
+
// A model working on the team's tasks with tools that run in this process: no server and no
|
|
13
|
+
// port. Every tool call passes through the same `authorize` and handlers as the commands.
|
|
14
|
+
|
|
15
|
+
// What a turn answers with: its text, the calls the model made and what they returned.
|
|
16
|
+
type Response = LanguageModel.GenerateTextResponse<typeof toolkit.tools, "opaque">;
|
|
17
|
+
|
|
18
|
+
// Printing only. A refusal or a declared error is a result too: JSON the model reads and may
|
|
19
|
+
// act on.
|
|
20
|
+
const report = (member: Member) => (response: Response) => {
|
|
21
|
+
const waiting = response.content.flatMap((part) =>
|
|
22
|
+
part.type === "tool-approval-request" ? [part.toolCallId] : [],
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return Console.log(
|
|
26
|
+
[
|
|
27
|
+
`As ${member.id}:`,
|
|
28
|
+
...response.toolResults.map(
|
|
29
|
+
({ name, encodedResult }) => ` ${name} -> ${JSON.stringify(encodedResult)}`,
|
|
30
|
+
),
|
|
31
|
+
...response.toolCalls.flatMap(({ id, name, params }) =>
|
|
32
|
+
waiting.includes(id) ? [` ${name} ${JSON.stringify(params)} waits for approval`] : [],
|
|
33
|
+
),
|
|
34
|
+
...(response.text === "" ? [] : [` "${response.text}"`]),
|
|
35
|
+
].join("\n"),
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// One turn of a conversation: the model reads the prompt and may call tools. The member is
|
|
40
|
+
// provided around the whole turn, so the approval rule, `authorize` and the handlers of every
|
|
41
|
+
// call in it see that member.
|
|
42
|
+
const turn = (chat: Chat.Chat, member: Member, prompt: Prompt.RawInput) =>
|
|
43
|
+
chat.generateText({ prompt, toolkit }).pipe(actingFor(member), Effect.tap(report(member)));
|
|
44
|
+
|
|
45
|
+
const conversations = Effect.gen(function* () {
|
|
46
|
+
// A `Chat` keeps the conversation's history, and approval depends on it: an answer is matched
|
|
47
|
+
// to its request by `approvalId` in the prompt. The answer alone, sent through a bare
|
|
48
|
+
// `LanguageModel.generateText`, runs nothing. Without a `Chat`, send again the assistant
|
|
49
|
+
// message holding the call and its request, before the answer.
|
|
50
|
+
const withAda = yield* Chat.empty;
|
|
51
|
+
|
|
52
|
+
// Ada's own task is added at once. The task for Grace is not run: the turn ends with a
|
|
53
|
+
// request for approval in place of that call's result.
|
|
54
|
+
const asked = yield* turn(withAda, members.ada, "Add the venue for me, invitations for Grace.");
|
|
55
|
+
|
|
56
|
+
// A real host shows each request to the person and waits. Their answers start the next turn,
|
|
57
|
+
// in which an approved call runs before the model is asked again. It runs as whoever that
|
|
58
|
+
// turn runs as, so the host provides the member who was asked: Ada still.
|
|
59
|
+
yield* turn(withAda, members.ada, [
|
|
60
|
+
{
|
|
61
|
+
role: "tool",
|
|
62
|
+
content: asked.content.flatMap((part) =>
|
|
63
|
+
part.type === "tool-approval-request"
|
|
64
|
+
? [{ type: "tool-approval-response", approvalId: part.approvalId, approved: true }]
|
|
65
|
+
: [],
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// The same toolkit, another member. The guest may read, so the write comes back refused.
|
|
71
|
+
yield* turn(yield* Chat.empty, members.guest, "What is there to do? Add lunch for me.");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const adding = (id: string, title: string, assignee: string) =>
|
|
75
|
+
({ type: "tool-call", id, name: "addTask", params: { title, assignee } }) as const;
|
|
76
|
+
|
|
77
|
+
// DEMO ONLY: what the model answers, request by request, so this runs without an API key.
|
|
78
|
+
const model = scripted(
|
|
79
|
+
[adding("1", "Book the venue", "ada"), adding("2", "Send invitations", "grace")],
|
|
80
|
+
[{ type: "text", text: "Both tasks are on the list." }],
|
|
81
|
+
[{ type: "tool-call", id: "3", name: "listTasks", params: {} }, adding("4", "Lunch", "guest")],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
conversations.pipe(
|
|
85
|
+
// A real `LanguageModel` plugs in here: the layer of a provider's model, with the layer of
|
|
86
|
+
// that provider's client, in place of the scripted one. Nothing below this line changes.
|
|
87
|
+
Effect.provide(model),
|
|
88
|
+
// Startup services only: where tasks and permissions are kept. Never a caller.
|
|
89
|
+
Effect.provide(layer.pipe(Layer.provide([TaskStore.layerFile, Permissions.layerMemory]))),
|
|
90
|
+
Effect.provide(NodeServices.layer),
|
|
91
|
+
NodeRuntime.runMain,
|
|
92
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { userInfo } from "node:os";
|
|
2
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
import { Command } from "effect/cli";
|
|
5
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
6
|
+
import { commands } from "./commands.js";
|
|
7
|
+
import { CurrentCaller, type Operator } from "./identity/Identity.js";
|
|
8
|
+
import { Permissions } from "./identity/permissions.js";
|
|
9
|
+
import { TaskStore } from "./tasks/store.js";
|
|
10
|
+
|
|
11
|
+
// Entrypoint: `node --import tsx examples/assistant/src/cli.ts list-tasks`.
|
|
12
|
+
// The operator's own tool. No credential arrives at a terminal, so this host names the caller
|
|
13
|
+
// itself: whoever runs the command, as the trusted operator. Being local earns nothing more:
|
|
14
|
+
// `authorize` runs here as for a model's tool call, and admits the operator by name.
|
|
15
|
+
const operator: Operator = { kind: "operator", id: userInfo().username };
|
|
16
|
+
|
|
17
|
+
const assistant = commands.pipe(
|
|
18
|
+
// Services go on the command, so they are built when an action runs: `--help` and a
|
|
19
|
+
// mistyped flag never open the tasks file.
|
|
20
|
+
Command.provide(Layer.mergeAll(TaskStore.layerFile, Permissions.layerMemory)),
|
|
21
|
+
Command.provideSync(CurrentCaller, operator),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// A command writes its result alone to stdout: JSON, or what `render` returns. Its failure
|
|
25
|
+
// goes to stderr under an `ERROR` heading, as JSON such as `{"_tag":"TaskNotFound","id":"9"}`,
|
|
26
|
+
// exiting 1, and so do its handler's logs, such as the store's. What happens outside the
|
|
27
|
+
// command is `runMain`'s to report, on stdout: a defect, such as a tasks file that does not
|
|
28
|
+
// decode. `logToStderr`, applied last, moves that to stderr too, so
|
|
29
|
+
// `assistant list-tasks --json | jq` reads results.
|
|
30
|
+
Command.run(assistant, { version: "1.0.0" }).pipe(
|
|
31
|
+
Effect.provide(NodeServices.layer),
|
|
32
|
+
ActionCli.logToStderr,
|
|
33
|
+
NodeRuntime.runMain,
|
|
34
|
+
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
2
|
+
import { identity } from "./identity/handlers.js";
|
|
3
|
+
import { tasks } from "./tasks/handlers.js";
|
|
4
|
+
|
|
5
|
+
// The same implementations as local commands: `assistant <action>`, a subcommand per action in
|
|
6
|
+
// kebab case, a flag per input field. A command runs the handler in this process, behind the
|
|
7
|
+
// same `authorize`. `add-task` and `who-am-i` are as derived; the others each change one thing.
|
|
8
|
+
export const commands = ActionCli.make([tasks, identity], {
|
|
9
|
+
name: "assistant",
|
|
10
|
+
commands: {
|
|
11
|
+
// `assistant list-tasks -a ada`: a short flag beside `--assignee`. `render` prints a line
|
|
12
|
+
// per task for a person, and adds `--json` for the JSON a script reads.
|
|
13
|
+
listTasks: {
|
|
14
|
+
aliases: { assignee: "a" },
|
|
15
|
+
render: ({ tasks }) =>
|
|
16
|
+
tasks
|
|
17
|
+
.map((task) => `${task.done ? "[x]" : "[ ]"} ${task.id} ${task.title} (${task.assignee})`)
|
|
18
|
+
.join("\n"),
|
|
19
|
+
},
|
|
20
|
+
// `assistant complete-task 2`: an argument instead of `--id 2`.
|
|
21
|
+
completeTask: { positional: ["id"] },
|
|
22
|
+
// `assistant annotate-task --id 2 < notes.md`: the notes are piped in, not a flag.
|
|
23
|
+
annotateTask: { stdin: "notes" },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Context, Schema } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
|
|
4
|
+
/** A teammate: what a sign-in can name. The assistant acts for one in each turn. */
|
|
5
|
+
export interface Member {
|
|
6
|
+
readonly kind: "member";
|
|
7
|
+
readonly id: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whoever runs the machine. Only a host builds one, in its own code: nothing that turns a
|
|
12
|
+
* credential into a caller is typed to return it, so no token can claim to be the operator.
|
|
13
|
+
*/
|
|
14
|
+
export interface Operator {
|
|
15
|
+
readonly kind: "operator";
|
|
16
|
+
readonly id: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Who is calling. A protected action declares it, `caller: CurrentCaller`. Nothing here
|
|
21
|
+
* authenticates anyone: the host provides it around each call, as the member a model acts for
|
|
22
|
+
* or as the operator at the terminal.
|
|
23
|
+
*/
|
|
24
|
+
export class CurrentCaller extends Context.Service<CurrentCaller, Member | Operator>()(
|
|
25
|
+
"assistant/CurrentCaller",
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
export const WhoAmI = Action.make("whoAmI", {
|
|
29
|
+
description: "Show who the assistant is acting for.",
|
|
30
|
+
success: { id: Schema.String, kind: Schema.Literals(["member", "operator"]) },
|
|
31
|
+
readOnly: true,
|
|
32
|
+
caller: CurrentCaller,
|
|
33
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { CurrentCaller } from "./Identity.js";
|
|
4
|
+
import { type Permission, Permissions } from "./permissions.js";
|
|
5
|
+
|
|
6
|
+
// The one rule, for a model's tool call and for a command alike. It is built as handlers are:
|
|
7
|
+
// this Effect runs once and gets the permission store, a startup service; the function it
|
|
8
|
+
// returns runs before every handler and reads the caller of that call.
|
|
9
|
+
export const authorize = Effect.gen(function* () {
|
|
10
|
+
const permissions = yield* Permissions;
|
|
11
|
+
|
|
12
|
+
return Effect.fn("authorize")(function* (action: Action.Any) {
|
|
13
|
+
const caller = yield* CurrentCaller;
|
|
14
|
+
|
|
15
|
+
// The operator is trusted, and has no entry in the store. Only a host can name one.
|
|
16
|
+
if (caller.kind === "operator") return;
|
|
17
|
+
|
|
18
|
+
const permission: Permission = action.readOnly ? "tasks:read" : "tasks:write";
|
|
19
|
+
|
|
20
|
+
if (!(yield* permissions.granted(caller.id)).includes(permission)) {
|
|
21
|
+
return yield* new Action.Forbidden({ message: `Requires ${permission}.` });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { authorize } from "./authorize.js";
|
|
4
|
+
import { CurrentCaller, WhoAmI } from "./Identity.js";
|
|
5
|
+
|
|
6
|
+
export const identity = Action.implement(
|
|
7
|
+
WhoAmI,
|
|
8
|
+
() => Effect.map(CurrentCaller, ({ id, kind }) => ({ id, kind })),
|
|
9
|
+
{ authorize },
|
|
10
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Member } from "./Identity.js";
|
|
2
|
+
|
|
3
|
+
// DEMO ONLY: the team, by name. A real host maps the user its chat platform has verified to a
|
|
4
|
+
// `Member`. Typed as members alone: no entry, and so no sign-in, can be the operator.
|
|
5
|
+
export const members = {
|
|
6
|
+
ada: { kind: "member", id: "ada" },
|
|
7
|
+
grace: { kind: "member", id: "grace" },
|
|
8
|
+
guest: { kind: "member", id: "guest" },
|
|
9
|
+
} as const satisfies Readonly<Record<string, Member>>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
export type Permission = "tasks:read" | "tasks:write";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What each member may do. Permissions are kept here, not in the identity, so changing one
|
|
7
|
+
* takes effect on the member's next call.
|
|
8
|
+
*/
|
|
9
|
+
export class Permissions extends Context.Service<
|
|
10
|
+
Permissions,
|
|
11
|
+
{ readonly granted: (memberId: string) => Effect.Effect<ReadonlyArray<Permission>> }
|
|
12
|
+
>()("assistant/Permissions") {
|
|
13
|
+
// DEMO ONLY: a fixed table. A real application reads its database or its directory.
|
|
14
|
+
static readonly layerMemory = Layer.sync(Permissions, () => {
|
|
15
|
+
const table = new Map<string, ReadonlyArray<Permission>>([
|
|
16
|
+
["ada", ["tasks:read", "tasks:write"]],
|
|
17
|
+
["grace", ["tasks:read", "tasks:write"]],
|
|
18
|
+
["guest", ["tasks:read"]],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
return Permissions.of({ granted: (memberId) => Effect.sync(() => table.get(memberId) ?? []) });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect, Layer, Ref, Stream } from "effect";
|
|
2
|
+
import { LanguageModel, type Response } from "effect/ai";
|
|
3
|
+
|
|
4
|
+
// DEMO ONLY: a model that needs no API key. It answers its first request with the first reply
|
|
5
|
+
// it was given, its second with the second, whatever the prompt says, and with nothing once
|
|
6
|
+
// they run out. It stands exactly where a provider's model layer goes, such as
|
|
7
|
+
// `OpenAiLanguageModel.model(...)` of `@effect/ai-openai`.
|
|
8
|
+
export const scripted = (...replies: ReadonlyArray<ReadonlyArray<Response.PartEncoded>>) =>
|
|
9
|
+
Layer.effect(
|
|
10
|
+
LanguageModel.LanguageModel,
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const answered = yield* Ref.make(0);
|
|
13
|
+
|
|
14
|
+
return yield* LanguageModel.make({
|
|
15
|
+
generateText: () =>
|
|
16
|
+
Effect.map(
|
|
17
|
+
Ref.getAndUpdate(answered, (count) => count + 1),
|
|
18
|
+
(count) => [...(replies[count] ?? [])],
|
|
19
|
+
),
|
|
20
|
+
streamText: () => Stream.empty,
|
|
21
|
+
});
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { CurrentCaller } from "../identity/Identity.js";
|
|
4
|
+
|
|
5
|
+
export const Task = Schema.Struct({
|
|
6
|
+
id: Schema.String,
|
|
7
|
+
title: Schema.String,
|
|
8
|
+
assignee: Schema.String,
|
|
9
|
+
addedBy: Schema.String,
|
|
10
|
+
notes: Schema.String,
|
|
11
|
+
done: Schema.Boolean,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type Task = typeof Task.Type;
|
|
15
|
+
|
|
16
|
+
// A declared error is part of the contract. A tool returns it to the model as a result it can
|
|
17
|
+
// read, and a command prints it on stderr as JSON and exits 1.
|
|
18
|
+
export class TaskNotFound extends Schema.TaggedError<TaskNotFound>()("TaskNotFound", {
|
|
19
|
+
id: Schema.String,
|
|
20
|
+
}) {}
|
|
21
|
+
|
|
22
|
+
// A field's description is what a model reads beside the tool, and the flag's help text.
|
|
23
|
+
const Id = Schema.String.annotate({ description: "The id listed beside the task." });
|
|
24
|
+
|
|
25
|
+
const Assignee = Schema.String.annotate({ description: "A teammate's name, such as ada." });
|
|
26
|
+
|
|
27
|
+
const team = { caller: CurrentCaller } as const;
|
|
28
|
+
|
|
29
|
+
export const ListTasks = Action.make("listTasks", {
|
|
30
|
+
...team,
|
|
31
|
+
description: "List the team's tasks, or one teammate's.",
|
|
32
|
+
input: { assignee: Schema.optionalKey(Assignee) },
|
|
33
|
+
success: { tasks: Schema.Array(Task) },
|
|
34
|
+
readOnly: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const AddTask = Action.make("addTask", {
|
|
38
|
+
...team,
|
|
39
|
+
description: "Add a task and assign it to a teammate.",
|
|
40
|
+
input: { title: Schema.String.check(Schema.isMinLength(1)), assignee: Assignee },
|
|
41
|
+
success: Task,
|
|
42
|
+
readOnly: false,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const CompleteTask = Action.make("completeTask", {
|
|
46
|
+
...team,
|
|
47
|
+
description: "Mark a task as done.",
|
|
48
|
+
input: { id: Id },
|
|
49
|
+
success: Task,
|
|
50
|
+
error: TaskNotFound,
|
|
51
|
+
readOnly: false,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const AnnotateTask = Action.make("annotateTask", {
|
|
55
|
+
...team,
|
|
56
|
+
description: "Replace a task's notes.",
|
|
57
|
+
input: { id: Id, notes: Schema.String.annotate({ description: "The notes, as plain text." }) },
|
|
58
|
+
success: Task,
|
|
59
|
+
error: TaskNotFound,
|
|
60
|
+
readOnly: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const Tasks = [ListTasks, AddTask, CompleteTask, AnnotateTask] as const;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { authorize } from "../identity/authorize.js";
|
|
4
|
+
import { CurrentCaller } from "../identity/Identity.js";
|
|
5
|
+
import { Tasks } from "./Tasks.js";
|
|
6
|
+
import { TaskStore } from "./store.js";
|
|
7
|
+
|
|
8
|
+
// The builder gets the store. It runs once for a toolkit's layer, however many calls a model
|
|
9
|
+
// makes, and each time a command runs. `CurrentCaller` is read per call. No handler knows whether a model or a person at a terminal
|
|
10
|
+
// called it, and none checks a permission: `authorize` has run before it.
|
|
11
|
+
export const tasks = Action.implement(
|
|
12
|
+
Tasks,
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const store = yield* TaskStore;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
listTasks: ({ assignee }) => Effect.map(store.list(assignee), (tasks) => ({ tasks })),
|
|
18
|
+
addTask: ({ title, assignee }) =>
|
|
19
|
+
Effect.flatMap(CurrentCaller, (caller) => store.add(caller.id, title, assignee)),
|
|
20
|
+
completeTask: ({ id }) => store.complete(id),
|
|
21
|
+
annotateTask: ({ id, notes }) => store.annotate(id, notes),
|
|
22
|
+
};
|
|
23
|
+
}),
|
|
24
|
+
{ authorize },
|
|
25
|
+
);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Config, Context, Effect, FileSystem, Layer, Ref, Schema, Semaphore } from "effect";
|
|
4
|
+
import { Task, TaskNotFound } from "./Tasks.js";
|
|
5
|
+
|
|
6
|
+
type TaskList = ReadonlyArray<Task>;
|
|
7
|
+
|
|
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.
|
|
12
|
+
const make = (load: Effect.Effect<TaskList>, save: (tasks: TaskList) => Effect.Effect<void>) => {
|
|
13
|
+
// A model's tool calls in one turn run concurrently. Two changes reading the list before
|
|
14
|
+
// either writes it would lose one, so a change holds this lock from its read to its write.
|
|
15
|
+
const lock = Semaphore.makeUnsafe(1);
|
|
16
|
+
|
|
17
|
+
const change = Effect.fn("TaskStore.change")(function* (id: string, to: (task: Task) => Task) {
|
|
18
|
+
const tasks = yield* load;
|
|
19
|
+
const task = tasks.find((each) => each.id === id);
|
|
20
|
+
|
|
21
|
+
if (task === undefined) return yield* new TaskNotFound({ id });
|
|
22
|
+
|
|
23
|
+
const changed = to(task);
|
|
24
|
+
|
|
25
|
+
yield* save(tasks.map((each) => (each.id === id ? changed : each)));
|
|
26
|
+
|
|
27
|
+
return changed;
|
|
28
|
+
}, Semaphore.withPermit(lock));
|
|
29
|
+
|
|
30
|
+
return TaskStore.of({
|
|
31
|
+
list: (assignee) =>
|
|
32
|
+
Effect.map(load, (tasks) =>
|
|
33
|
+
assignee === undefined ? tasks : tasks.filter((task) => task.assignee === assignee),
|
|
34
|
+
),
|
|
35
|
+
add: Effect.fn("TaskStore.add")(function* (addedBy, title, assignee) {
|
|
36
|
+
const tasks = yield* load;
|
|
37
|
+
const task = { id: `${tasks.length + 1}`, title, assignee, addedBy, notes: "", done: false };
|
|
38
|
+
|
|
39
|
+
yield* save([...tasks, task]);
|
|
40
|
+
|
|
41
|
+
return task;
|
|
42
|
+
}, Semaphore.withPermit(lock)),
|
|
43
|
+
complete: (id) => change(id, (task) => ({ ...task, done: true })),
|
|
44
|
+
annotate: (id, notes) => change(id, (task) => ({ ...task, notes })),
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The team's tasks. Its methods require nothing: a layer captures where the tasks are kept. */
|
|
49
|
+
export class TaskStore extends Context.Service<
|
|
50
|
+
TaskStore,
|
|
51
|
+
{
|
|
52
|
+
readonly list: (assignee: string | undefined) => Effect.Effect<TaskList>;
|
|
53
|
+
readonly add: (addedBy: string, title: string, assignee: string) => Effect.Effect<Task>;
|
|
54
|
+
readonly complete: (id: string) => Effect.Effect<Task, TaskNotFound>;
|
|
55
|
+
readonly annotate: (id: string, notes: string) => Effect.Effect<Task, TaskNotFound>;
|
|
56
|
+
}
|
|
57
|
+
>()("assistant/TaskStore") {
|
|
58
|
+
/** Memory, lost with the process: what a test provides. */
|
|
59
|
+
static readonly layerMemory = Layer.effect(
|
|
60
|
+
TaskStore,
|
|
61
|
+
Effect.map(Ref.make<TaskList>([]), (tasks) =>
|
|
62
|
+
make(Ref.get(tasks), (next) => Ref.set(tasks, next)),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
|
|
3
|
+
import { identity } from "./identity/handlers.js";
|
|
4
|
+
import { CurrentCaller, type Member } from "./identity/Identity.js";
|
|
5
|
+
import { tasks } from "./tasks/handlers.js";
|
|
6
|
+
|
|
7
|
+
// The implementations as a native Effect AI Toolkit: a tool per action, run in this process.
|
|
8
|
+
// `toolkit` goes to `LanguageModel.generateText`; `layer` holds the handlers, and requires the
|
|
9
|
+
// startup services their builders read. Neither holds a caller.
|
|
10
|
+
export const { toolkit, layer } = ActionToolkit.make([tasks, identity], {
|
|
11
|
+
// One rule over every call a model makes, asked before the call runs. A read runs at once. A
|
|
12
|
+
// write waits for a person to approve it, except a task the caller adds to their own list:
|
|
13
|
+
// `call.name` narrows `call.input` to that action's. Without a caller, it asks.
|
|
14
|
+
// This is not authorization: an approved call still passes through `authorize`.
|
|
15
|
+
needsApproval: (call) =>
|
|
16
|
+
!call.action.readOnly &&
|
|
17
|
+
Effect.map(
|
|
18
|
+
Effect.serviceOption(CurrentCaller),
|
|
19
|
+
Option.match({
|
|
20
|
+
onNone: () => true,
|
|
21
|
+
onSome: (caller) => call.name !== "addTask" || call.input.assignee !== caller.id,
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Who a turn runs as is provided around that turn, never to `layer`: one toolkit serves every
|
|
27
|
+
// member. This is how this example names a model's caller, and it takes a `Member`. The tools
|
|
28
|
+
// themselves accept any `CurrentCaller`, so keeping the operator away from a model is this
|
|
29
|
+
// function's doing, and that of every host using it.
|
|
30
|
+
export const actingFor = (member: Member) => Effect.provideService(CurrentCaller, member);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NodeServices } from "@effect/platform-node";
|
|
2
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
3
|
+
import { Effect, Layer, Stdio, Stream } from "effect";
|
|
4
|
+
import { Command } from "effect/cli";
|
|
5
|
+
import { TestConsole } from "effect/testing";
|
|
6
|
+
import { commands } from "../src/commands.js";
|
|
7
|
+
import { CurrentCaller, type Operator } from "../src/identity/Identity.js";
|
|
8
|
+
import { Permissions } from "../src/identity/permissions.js";
|
|
9
|
+
import { TaskStore } from "../src/tasks/store.js";
|
|
10
|
+
|
|
11
|
+
// What the commands add to the implementations: their syntax and what they print. The services
|
|
12
|
+
// are provided around a test, not on the command as cli.ts does, so the commands of one test
|
|
13
|
+
// share one store in memory.
|
|
14
|
+
const services = Layer.mergeAll(TaskStore.layerMemory, Permissions.layerMemory);
|
|
15
|
+
|
|
16
|
+
const operator: Operator = { kind: "operator", id: "root" };
|
|
17
|
+
|
|
18
|
+
// Effect's own runner, given arguments instead of the process's. `it.effect` provides a console
|
|
19
|
+
// that keeps what is printed, and stdin is the text given here.
|
|
20
|
+
const run = (args: ReadonlyArray<string>, stdin = "") =>
|
|
21
|
+
Command.runWith(commands.pipe(Command.provideSync(CurrentCaller, operator)), { version: "0" })(
|
|
22
|
+
args,
|
|
23
|
+
).pipe(
|
|
24
|
+
Effect.andThen(TestConsole.logLines),
|
|
25
|
+
Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })),
|
|
26
|
+
Effect.provide(NodeServices.layer),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
describe("the commands", () => {
|
|
30
|
+
it.effect("run as the operator the host names, taking an argument and stdin", () =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
yield* run(["add-task", "--title", "Rotate the keys", "--assignee", "ada"]);
|
|
33
|
+
yield* run(["annotate-task", "--id", "1"], "Before Friday.\n");
|
|
34
|
+
yield* run(["complete-task", "1"]);
|
|
35
|
+
|
|
36
|
+
const store = yield* TaskStore;
|
|
37
|
+
|
|
38
|
+
expect(yield* store.list("ada")).toMatchObject([
|
|
39
|
+
{ addedBy: "root", notes: "Before Friday.", done: true },
|
|
40
|
+
]);
|
|
41
|
+
}).pipe(Effect.provide(services)),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
it.effect("print what `render` returns, and JSON where a command states none", () =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
yield* run(["add-task", "--title", "Rotate the keys", "--assignee", "ada"]);
|
|
47
|
+
|
|
48
|
+
const printed = yield* run(["list-tasks", "-a", "ada"]);
|
|
49
|
+
|
|
50
|
+
expect(printed.at(-1)).toBe("[ ] 1 Rotate the keys (ada)");
|
|
51
|
+
|
|
52
|
+
const json = yield* run(["who-am-i"]);
|
|
53
|
+
|
|
54
|
+
expect(json.at(-1)).toBe(JSON.stringify({ id: "root", kind: "operator" }, undefined, 2));
|
|
55
|
+
}).pipe(Effect.provide(services)),
|
|
56
|
+
);
|
|
57
|
+
});
|