@soft-boy/agentschema 0.1.1 → 0.2.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 +118 -49
- package/bin/agentschema.js +98 -24
- package/package.json +1 -1
- package/src/chat.js +20 -2
- package/src/check.js +12 -6
- package/src/delete.js +25 -0
- package/src/deploy.js +2 -2
- package/src/init.js +186 -0
- package/src/instance.js +7 -32
- package/src/list.js +49 -0
- package/src/markdown.js +30 -0
- package/src/session.js +13 -10
- package/src/tools.js +210 -0
package/README.md
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
# agentschema
|
|
2
2
|
|
|
3
|
-
Define
|
|
3
|
+
Define an AI-Agent as a folder of Markdown files.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
my-agent/
|
|
7
|
-
├── agent.md
|
|
8
|
-
|
|
7
|
+
├── agent.md # the agent
|
|
8
|
+
├── env.md # the environment it runs in
|
|
9
|
+
└── tools/ # one file per tool, in any folders you like
|
|
10
|
+
├── built-in/
|
|
11
|
+
│ ├── bash.md
|
|
12
|
+
│ └── read.md
|
|
13
|
+
└── read_file.md
|
|
9
14
|
```
|
|
10
15
|
|
|
11
|
-
**Status:** v0.
|
|
16
|
+
**Status:** v0.2 draft. v0.2 breaks from the Claude Managed Agents schema: tools move out of `agent.md` and into `tools/`.
|
|
12
17
|
|
|
13
18
|
## Philosophy
|
|
14
19
|
|
|
15
20
|
1. **One folder, one agent.** A folder defines one agent and the one environment it runs in. Sessions, credentials, mounted files and repositories, and schedules are supplied by whatever runs the agent.
|
|
16
21
|
2. **Everything is a Markdown file.** An agent is plain files you can read, diff, review and version like any other code. Structured settings go in YAML frontmatter, and prose the model reads goes in the Markdown body.
|
|
17
|
-
3. **Inspired by Claude Managed Agents, not tied to it.**
|
|
22
|
+
3. **Inspired by Claude Managed Agents, not tied to it.** `agent.md` and `env.md` use the Claude Managed Agents fields because they're a well-documented vocabulary for agents. Tools are the exception: each one is its own file, and agentschema compiles them into the API's format. The format itself assumes no particular infrastructure, so any runtime that can read the files can run the agent.
|
|
18
23
|
|
|
19
24
|
## Install
|
|
20
25
|
|
|
@@ -22,53 +27,29 @@ my-agent/
|
|
|
22
27
|
npm install -g @soft-boy/agentschema
|
|
23
28
|
```
|
|
24
29
|
|
|
25
|
-
## Example
|
|
26
|
-
|
|
27
|
-
[`examples/coding-agent/agent.md`](examples/coding-agent/agent.md):
|
|
28
|
-
|
|
29
|
-
```markdown
|
|
30
|
-
---
|
|
31
|
-
name: Coding Assistant
|
|
32
|
-
model: claude-opus-5-5
|
|
33
|
-
tools:
|
|
34
|
-
- type: agent_toolset_20260401
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
You are a helpful coding assistant. Write clean, well-documented code.
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
[`examples/coding-agent/env.md`](examples/coding-agent/env.md):
|
|
41
|
-
|
|
42
|
-
```markdown
|
|
43
|
-
---
|
|
44
|
-
name: quickstart
|
|
45
|
-
config:
|
|
46
|
-
type: cloud
|
|
47
|
-
networking:
|
|
48
|
-
type: limited
|
|
49
|
-
allow_package_managers: true
|
|
50
|
-
---
|
|
51
|
-
Cloud container with limited networking and package manager access.
|
|
52
|
-
```
|
|
53
|
-
|
|
54
30
|
## Specification
|
|
55
31
|
|
|
56
32
|
### Files
|
|
57
33
|
|
|
58
|
-
An instance is a folder of Markdown files.
|
|
34
|
+
An instance is a folder of Markdown files. `agent.md` and `env.md` are required; `tools/` is optional.
|
|
59
35
|
|
|
60
36
|
```
|
|
61
37
|
my-agent/
|
|
62
|
-
├── agent.md
|
|
63
|
-
|
|
38
|
+
├── agent.md # the agent
|
|
39
|
+
├── env.md # the environment it runs in
|
|
40
|
+
└── tools/ # one file per tool, in any folders you like
|
|
41
|
+
├── built-in/
|
|
42
|
+
│ ├── bash.md
|
|
43
|
+
│ └── read.md
|
|
44
|
+
└── read_file.md
|
|
64
45
|
```
|
|
65
46
|
|
|
66
47
|
### `agent.md`
|
|
67
48
|
Field reference: [Agent setup](https://platform.claude.com/docs/en/managed-agents/agent-setup).
|
|
68
49
|
|
|
69
|
-
- **Frontmatter** uses the fields of a Claude Managed Agents agent (the `POST /v1/agents` request body), written as YAML. `name` and `model` are required. Every other field (`description`, `
|
|
50
|
+
- **Frontmatter** uses the fields of a Claude Managed Agents agent (the `POST /v1/agents` request body), written as YAML. `name` and `model` are required. Every other field (`description`, `mcp_servers`, `skills`, `multiagent`, `metadata`, …) is optional and uses the same names and shapes.
|
|
70
51
|
- **Body** is the agent's system prompt, sent as `system` exactly as written (leading and trailing whitespace trimmed). An empty body means no system prompt.
|
|
71
|
-
- Setting `system` in the frontmatter is an error, because the body is the system prompt.
|
|
52
|
+
- Setting `system` in the frontmatter is an error, because the body is the system prompt. So is `tools`, because tools live in `tools/`.
|
|
72
53
|
|
|
73
54
|
### `env.md`
|
|
74
55
|
Field reference: [Environments](https://platform.claude.com/docs/en/managed-agents/environments).
|
|
@@ -76,49 +57,137 @@ Field reference: [Environments](https://platform.claude.com/docs/en/managed-agen
|
|
|
76
57
|
- **Frontmatter** uses the fields of a Claude Managed Agents environment (the `POST /v1/environments` request body), written as YAML: `name`, `config`, and any other field the schema defines.
|
|
77
58
|
- **Body** is a note for people reading the folder. It is not sent anywhere.
|
|
78
59
|
|
|
60
|
+
### `tools/`
|
|
61
|
+
|
|
62
|
+
Each `.md` file in `tools/` is one tool, and the file name is the tool's name. agentschema supports two kinds: pre-built and custom.
|
|
63
|
+
|
|
64
|
+
Subfolders are only for organizing. Every `.md` file under `tools/`, at any depth, is a tool, and its folder is ignored when compiling: moving a file between folders changes nothing that's deployed. Because the folders are dropped, two tools can't share a name even in different folders. Folders and files whose names start with `.` are skipped. By convention, the pre-built tools go in `tools/built-in/`.
|
|
65
|
+
|
|
66
|
+
**Pre-built tools** are the tools Claude Managed Agents runs in the container: `bash`, `edit`, `glob`, `grep`, `read`, `web_fetch`, `web_search` and `write`. Name the file after the tool (for example `tools/built-in/bash.md`), and give it only a `type`:
|
|
67
|
+
|
|
68
|
+
```markdown
|
|
69
|
+
---
|
|
70
|
+
type: agent_toolset_20260401
|
|
71
|
+
---
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Only the pre-built tools that have a file are enabled; delete a file to disable that tool. The body is a note for people and isn't sent anywhere.
|
|
75
|
+
|
|
76
|
+
**Custom tools** run on your side: when the agent calls one, `agentschema run` runs its code on your machine and sends back the result.
|
|
77
|
+
|
|
78
|
+
````markdown
|
|
79
|
+
---
|
|
80
|
+
type: custom
|
|
81
|
+
name: read_file
|
|
82
|
+
description: Read a file and output its contents as a string.
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Input Schema
|
|
86
|
+
|
|
87
|
+
| Name | Type | Description | Required |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| `filePath` | `string` | The path of the file to read | `true` |
|
|
90
|
+
|
|
91
|
+
## Code
|
|
92
|
+
|
|
93
|
+
```javascript
|
|
94
|
+
const fs = require('fs').promises;
|
|
95
|
+
|
|
96
|
+
module.exports = async ({ filePath }) => {
|
|
97
|
+
try {
|
|
98
|
+
return await fs.readFile(filePath, 'utf-8');
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return `Error reading file: ${error.message}`;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
````
|
|
105
|
+
|
|
106
|
+
- **Frontmatter:** `type: custom`, a `name` that matches the file name, and a `description` the model reads to decide when to call the tool. The name can't reuse a pre-built tool's name (so `read_file`, not `read`), because the API rejects that.
|
|
107
|
+
- **`## Input Schema`** is a table with the columns Name, Type, Description and Required. Type is `string`, `number`, `integer`, `boolean`, `object` or `array`, or one of those followed by `[]` for a list. Leave the section out for a tool that takes no input.
|
|
108
|
+
- **`## Code`** is required, and must be one JavaScript code block. It's a CommonJS module, and `module.exports` is the function. It's called with the call's input as an object. A string it returns (or resolves to) is sent as the result; anything else is sent as JSON. If it throws, the error message is sent as an error result. `require()` resolves from the tool's file, so packages installed in the agent folder work.
|
|
109
|
+
- Other sections are notes for people.
|
|
110
|
+
|
|
111
|
+
**Compiling.** `deploy` turns `tools/` into the agent's `tools` list, sorted by tool name: one `agent_toolset_20260401` entry with `default_config: { enabled: false }` and a config enabling each pre-built tool with a file, then the custom tools. A custom tool sends only its name, description and input schema; the code never leaves your machine.
|
|
112
|
+
|
|
79
113
|
## Usage
|
|
80
114
|
|
|
81
115
|
```sh
|
|
116
|
+
agentschema init <name> # scaffold a new agent folder
|
|
82
117
|
agentschema check [dir] # validate the folder, no network
|
|
83
118
|
agentschema deploy [dir] # create or update the agent and environment
|
|
84
119
|
agentschema run [dir] # chat with the deployed agent in the terminal
|
|
120
|
+
agentschema list # list the agents in your workspace
|
|
121
|
+
agentschema delete <agent_id> # archive an agent
|
|
122
|
+
agentschema sessions list # list the sessions in your workspace
|
|
123
|
+
agentschema sessions delete <id> # delete a session
|
|
85
124
|
```
|
|
86
125
|
|
|
87
126
|
`dir` defaults to the current directory.
|
|
88
127
|
|
|
89
|
-
**`
|
|
128
|
+
**`init`** creates the folder `<name>` with an `agent.md` and an `env.md` that pass `check`. It asks for:
|
|
129
|
+
- the agent's name and model
|
|
130
|
+
- its system prompt
|
|
131
|
+
- whether it gets the pre-built tools; if so, `init` writes a file for each one in `tools/built-in/`
|
|
132
|
+
- the environment's name and networking
|
|
133
|
+
|
|
134
|
+
Press Enter to keep the default shown, or pass `--yes` to skip the questions. `init` won't write into a folder that already has files in it.
|
|
135
|
+
|
|
136
|
+
**`check`** reads every file and reports every problem it finds:
|
|
90
137
|
- a missing file, or a name that isn't lowercase
|
|
91
138
|
- frontmatter that is missing, unclosed, or invalid YAML
|
|
92
|
-
- a missing `name` or `model`, or a `system` key in `agent.md`'s frontmatter
|
|
139
|
+
- a missing `name` or `model`, or a `system` or `tools` key in `agent.md`'s frontmatter
|
|
140
|
+
- a tool file that isn't a known pre-built tool, or a custom tool with a missing description, a malformed input table, or code that isn't JavaScript or doesn't parse
|
|
93
141
|
- values over the API's limits
|
|
94
142
|
- anything that looks like an API key or token
|
|
95
143
|
|
|
96
144
|
Unknown fields are only warnings, because they're sent to the API as written. `check` exits with status 1 when there are errors.
|
|
97
145
|
|
|
98
146
|
**`deploy`** runs `check` first, then creates or updates the environment and the agent, and records their IDs in `claude-lock.json` in the folder. Commit that file.
|
|
99
|
-
- **Later runs update the same resources.** A file that hasn't changed since the last deploy is skipped. Each change to `agent.md` adds a new version of the agent, and sessions already running keep the version they started on.
|
|
147
|
+
- **Later runs update the same resources.** A file that hasn't changed since the last deploy is skipped. Each change to `agent.md` or `tools/` adds a new version of the agent, and sessions already running keep the version they started on.
|
|
100
148
|
- **The files are the source of truth.** A field you delete from a file is cleared on the next deploy, rather than left at its old value.
|
|
101
149
|
- **Changes made elsewhere aren't overwritten silently.** If the agent was changed outside the folder since the last deploy, `deploy` stops; `--force` overwrites it.
|
|
102
150
|
|
|
103
|
-
`deploy` authenticates with `ANTHROPIC_API_KEY`, or with a profile from `ant auth login`.
|
|
104
|
-
|
|
105
151
|
`claude-lock.json` uses the same layout as `ant apply`'s, so `deploy` updates resources that `ant apply` created. The two tools hash files differently, though, so the first `deploy` after an `ant apply` updates both resources even if nothing changed.
|
|
106
152
|
|
|
107
153
|
**`run`** starts a session on the agent and environment in `claude-lock.json`, then opens a chat in the terminal. Deploy first; if you've edited the files since, `run` warns you and still uses the deployed version.
|
|
108
154
|
- **Messages:** type a message and press Enter. The agent's replies stream in, with a one-line summary of each tool call.
|
|
109
155
|
- **Approvals:** when a tool call needs approval (`always_ask`, or `auto` when the server can't decide), `run` asks you. Answer `y` to allow it, or type a reason to deny it; the agent sees the reason.
|
|
110
|
-
- **Custom tools:** `run` shows the call's input and asks you to type the result.
|
|
156
|
+
- **Custom tools:** `run` runs the tool's code from `tools/` and sends back the result. The code runs on your machine with your permissions, like any script in the folder. If the folder doesn't pass `check`, or the tool isn't in it (for example, when you resume a session from somewhere else), `run` shows the call's input and asks you to type the result.
|
|
111
157
|
- **Interrupting:** Ctrl+C interrupts the agent mid-turn, and a second Ctrl+C quits. Ctrl+D quits at the prompt.
|
|
112
|
-
- **Resuming:** quitting leaves the session in place, and `run` prints a command to resume it: `agentschema run --
|
|
158
|
+
- **Resuming:** quitting leaves the session in place, and `run` prints a command to resume it: `agentschema run --resume sesn_…`.
|
|
113
159
|
|
|
114
160
|
```sh
|
|
115
|
-
agentschema run
|
|
116
|
-
agentschema run [dir] --title "Bug triage" # name the session
|
|
117
|
-
agentschema run --session sesn_… # resume an existing session
|
|
161
|
+
agentschema run --resume sesn_… # resume an existing session
|
|
118
162
|
```
|
|
119
163
|
|
|
120
164
|
To run the agent from your own code, create a session with the agent and environment IDs from the lock file.
|
|
121
165
|
|
|
166
|
+
**`list`** prints every agent in your workspace, with its ID, latest version, model and when it was last updated. It includes agents that weren't deployed with agentschema, and leaves out archived ones.
|
|
167
|
+
|
|
168
|
+
**`sessions list`** prints every session in your workspace, newest first, with its status, the agent and version it runs, and its title. Pass an ID to `run --resume` to pick a session back up.
|
|
169
|
+
|
|
170
|
+
**`delete`** archives an agent. The API can't delete agents outright, so this is as close as it gets. An archived agent drops out of `list`, and the API has no way to unarchive it. If the agent is in a folder's `claude-lock.json`, remove its entry so the next `deploy` creates a new one.
|
|
171
|
+
|
|
172
|
+
**`sessions delete`** permanently deletes a session and its event history. A running session can't be deleted; wait for it to go idle, or interrupt it, first.
|
|
173
|
+
|
|
174
|
+
Both ask for confirmation first, showing the agent's name or the session's title. Pass `--yes` to skip that, for example in scripts; without a terminal to ask in, they refuse unless you pass `--yes`.
|
|
175
|
+
|
|
176
|
+
### Authentication
|
|
177
|
+
|
|
178
|
+
agentschema has no account setting of its own. Every command except `init` and `check` uses whichever Anthropic credentials are in your shell:
|
|
179
|
+
|
|
180
|
+
1. `ANTHROPIC_API_KEY`, if it's set
|
|
181
|
+
2. otherwise, the profile you signed in to with `ant auth login` (the active one, or the one `ANTHROPIC_PROFILE` names)
|
|
182
|
+
|
|
183
|
+
An API key belongs to one workspace, and the agent and environment are created in that workspace. Each person deploys with their own key, and to deploy somewhere else you switch the key:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
ANTHROPIC_API_KEY=sk-ant-… agentschema deploy my-agent
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`claude-lock.json` holds the IDs from one workspace. A committed lock works for everyone whose key is in that workspace, and they all update the same agent. If you deploy the folder from a key in a different workspace, `deploy` can't find those IDs and stops. In that case, deploy from a copy of the folder, or don't commit that lock file.
|
|
190
|
+
|
|
122
191
|
## Development
|
|
123
192
|
|
|
124
193
|
```sh
|
package/bin/agentschema.js
CHANGED
|
@@ -1,44 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
3
4
|
import { parseArgs } from "node:util";
|
|
4
5
|
import Anthropic from "@anthropic-ai/sdk";
|
|
5
6
|
import { chat } from "../src/chat.js";
|
|
6
7
|
import { check } from "../src/check.js";
|
|
8
|
+
import { DeleteError, deleteAgent, deleteSession } from "../src/delete.js";
|
|
7
9
|
import { DeployError, deploy } from "../src/deploy.js";
|
|
10
|
+
import { InitError, init } from "../src/init.js";
|
|
11
|
+
import { listAgents, listSessions } from "../src/list.js";
|
|
8
12
|
import { SessionError, openSession } from "../src/session.js";
|
|
9
13
|
|
|
10
14
|
const USAGE = `Usage: agentschema <command> [dir] [options]
|
|
11
15
|
|
|
12
16
|
Commands:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
init <name> Scaffold a new agent folder, asking a few questions
|
|
18
|
+
check [dir] Validate agent.md and env.md, without calling the API
|
|
19
|
+
deploy [dir] Create or update the agent and environment, and write claude-lock.json
|
|
20
|
+
run [dir] Chat with the deployed agent in a new session
|
|
21
|
+
list List the agents in your workspace
|
|
22
|
+
delete <agent_id> Archive an agent (agents can't be deleted outright)
|
|
23
|
+
sessions list List the sessions in your workspace, newest first
|
|
24
|
+
sessions delete <id> Delete a session and its history
|
|
16
25
|
|
|
17
26
|
Options:
|
|
27
|
+
-y, --yes (init) Skip the questions and use the defaults
|
|
28
|
+
(delete) Don't ask for confirmation
|
|
18
29
|
--dry-run (deploy) Show what would be created or updated, change nothing
|
|
19
30
|
--force (deploy) Update the agent even if it was changed outside this folder
|
|
20
|
-
--
|
|
21
|
-
--title <title> (run) Title for the new session
|
|
22
|
-
--session <id> (run) Resume an existing session (sesn_...) instead of starting one
|
|
31
|
+
--resume <id> (run) Resume an existing session (sesn_...) instead of starting one
|
|
23
32
|
-h, --help Show this help
|
|
24
33
|
-v, --version Show the version
|
|
25
34
|
|
|
26
|
-
dir defaults to the current directory.
|
|
35
|
+
dir defaults to the current directory. Every command but init and check authenticates with
|
|
27
36
|
ANTHROPIC_API_KEY, or with a profile from \`ant auth login\`.`;
|
|
28
37
|
|
|
29
38
|
const OPTIONS = {
|
|
39
|
+
yes: { type: "boolean", short: "y" },
|
|
30
40
|
"dry-run": { type: "boolean" },
|
|
31
41
|
force: { type: "boolean" },
|
|
32
|
-
|
|
33
|
-
title: { type: "string" },
|
|
34
|
-
session: { type: "string" },
|
|
42
|
+
resume: { type: "string" },
|
|
35
43
|
help: { type: "boolean", short: "h" },
|
|
36
44
|
version: { type: "boolean", short: "v" },
|
|
37
45
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
// For each command: how many positional arguments it takes, and which options.
|
|
47
|
+
const COMMANDS = {
|
|
48
|
+
init: { args: [1, 1], options: ["yes"] },
|
|
49
|
+
check: { args: [0, 1], options: [] },
|
|
50
|
+
deploy: { args: [0, 1], options: ["dry-run", "force"] },
|
|
51
|
+
run: { args: [0, 1], options: ["resume"] },
|
|
52
|
+
list: { args: [0, 0], options: [] },
|
|
53
|
+
delete: { args: [1, 1], options: ["yes"] },
|
|
54
|
+
"sessions list": { args: [0, 0], options: [] },
|
|
55
|
+
"sessions delete": { args: [1, 1], options: ["yes"] },
|
|
42
56
|
};
|
|
43
57
|
|
|
44
58
|
async function main(argv) {
|
|
@@ -50,7 +64,8 @@ async function main(argv) {
|
|
|
50
64
|
return 1;
|
|
51
65
|
}
|
|
52
66
|
const { values, positionals } = parsed;
|
|
53
|
-
|
|
67
|
+
let [command, ...args] = positionals;
|
|
68
|
+
if (command === "sessions") command = `sessions ${args.shift() ?? ""}`.trim();
|
|
54
69
|
|
|
55
70
|
if (values.version) {
|
|
56
71
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -61,16 +76,38 @@ async function main(argv) {
|
|
|
61
76
|
console.log(USAGE);
|
|
62
77
|
return command || values.help ? 0 : 1;
|
|
63
78
|
}
|
|
64
|
-
if (!(command in
|
|
79
|
+
if (command.startsWith("sessions") && !(command in COMMANDS)) {
|
|
80
|
+
console.error(`agentschema: usage: agentschema sessions list | sessions delete <id>`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
if (!(command in COMMANDS)) {
|
|
65
84
|
console.error(`agentschema: unknown command "${command}"\n\n${USAGE}`);
|
|
66
85
|
return 1;
|
|
67
86
|
}
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
const spec = COMMANDS[command];
|
|
88
|
+
const misplaced = Object.keys(values).filter((k) => !spec.options.includes(k));
|
|
89
|
+
if (args.length > spec.args[1] || misplaced.length > 0) {
|
|
90
|
+
const arg = args[spec.args[1]] ?? `--${misplaced[0]}`;
|
|
71
91
|
console.error(`agentschema: unexpected argument ${arg} for ${command}\n\n${USAGE}`);
|
|
72
92
|
return 1;
|
|
73
93
|
}
|
|
94
|
+
if (args.length < spec.args[0]) {
|
|
95
|
+
const example = { init: "init my-agent", delete: "delete agent_…", "sessions delete": "sessions delete sesn_…" }[command];
|
|
96
|
+
console.error(`agentschema: ${command} needs an argument, e.g. agentschema ${example}`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const dir = args[0] ?? ".";
|
|
100
|
+
|
|
101
|
+
if (command === "init") {
|
|
102
|
+
try {
|
|
103
|
+
await init(dir, { yes: values.yes ?? false });
|
|
104
|
+
return 0;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (!(err instanceof InitError)) throw err;
|
|
107
|
+
console.error(`error: ${err.message}`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
74
111
|
|
|
75
112
|
if (command === "check") {
|
|
76
113
|
const { errors, warnings } = check(dir);
|
|
@@ -94,16 +131,29 @@ async function main(argv) {
|
|
|
94
131
|
return 0;
|
|
95
132
|
}
|
|
96
133
|
|
|
134
|
+
if (command === "list" || command === "sessions list") {
|
|
135
|
+
const lines = command === "list" ? await listAgents(makeClient(false)) : await listSessions(makeClient(false));
|
|
136
|
+
for (const line of lines) console.log(line);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (command === "delete" || command === "sessions delete") {
|
|
141
|
+
const remove = command === "delete" ? deleteAgent : deleteSession;
|
|
142
|
+
await remove(makeClient(false), args[0], {
|
|
143
|
+
confirm: values.yes ? async () => true : confirm,
|
|
144
|
+
log: (line) => console.log(line),
|
|
145
|
+
});
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
97
149
|
if (command === "run") {
|
|
98
150
|
const client = makeClient(false);
|
|
99
151
|
const { session, notes } = await openSession(dir, {
|
|
100
152
|
client,
|
|
101
|
-
sessionId: values.
|
|
102
|
-
vaultIds: values.vault ?? [],
|
|
103
|
-
title: values.title,
|
|
153
|
+
sessionId: values.resume,
|
|
104
154
|
});
|
|
105
155
|
for (const note of notes) console.warn(`warning: ${note}`);
|
|
106
|
-
await chat(client, session, { agentName: session.agent?.name ?? "agent" });
|
|
156
|
+
await chat(client, session, { agentName: session.agent?.name ?? "agent", tools: customTools(dir) });
|
|
107
157
|
return 0;
|
|
108
158
|
}
|
|
109
159
|
} catch (err) {
|
|
@@ -111,7 +161,7 @@ async function main(argv) {
|
|
|
111
161
|
console.error(`error: ${err.status ?? "network error"}: ${err.error?.error?.message ?? err.message}`);
|
|
112
162
|
return 1;
|
|
113
163
|
}
|
|
114
|
-
if (err instanceof DeployError || err instanceof SessionError || err instanceof Anthropic.AnthropicError) {
|
|
164
|
+
if (err instanceof DeployError || err instanceof DeleteError || err instanceof SessionError || err instanceof Anthropic.AnthropicError) {
|
|
115
165
|
console.error(`error: ${err.message}`);
|
|
116
166
|
return 1;
|
|
117
167
|
}
|
|
@@ -119,6 +169,30 @@ async function main(argv) {
|
|
|
119
169
|
}
|
|
120
170
|
}
|
|
121
171
|
|
|
172
|
+
// Asks a yes/no question in the terminal; without one, there's no one to ask.
|
|
173
|
+
async function confirm(question) {
|
|
174
|
+
if (!process.stdin.isTTY) throw new DeleteError("not a terminal, so can't ask for confirmation; pass --yes");
|
|
175
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
176
|
+
try {
|
|
177
|
+
return /^(y|yes)$/i.test((await rl.question(`${question} [y/N] `)).trim());
|
|
178
|
+
} finally {
|
|
179
|
+
rl.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The folder's custom tools, by name, for run to execute. A folder that doesn't pass check
|
|
184
|
+
// runs none of them; run asks you for each result instead.
|
|
185
|
+
function customTools(dir) {
|
|
186
|
+
const { instance, errors } = check(dir);
|
|
187
|
+
const customs = instance?.tools.filter((t) => t.data.type === "custom") ?? [];
|
|
188
|
+
if (customs.length === 0) return {};
|
|
189
|
+
if (errors.length > 0) {
|
|
190
|
+
console.warn(`warning: ${dir} doesn't pass check, so its custom tools won't run; you'll be asked for each result`);
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
return Object.fromEntries(customs.map((t) => [t.name, t]));
|
|
194
|
+
}
|
|
195
|
+
|
|
122
196
|
// A dry run makes no API calls, so it works without credentials.
|
|
123
197
|
function makeClient(dryRun) {
|
|
124
198
|
try {
|
package/package.json
CHANGED
package/src/chat.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { Conversation } from "./session.js";
|
|
3
|
+
import { loadTool } from "./tools.js";
|
|
3
4
|
|
|
4
5
|
const color = (code) => (text) => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
5
6
|
const dim = color(2);
|
|
@@ -53,8 +54,20 @@ function firstLine(content = []) {
|
|
|
53
54
|
|
|
54
55
|
const truncate = (s, n = 100) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
55
56
|
|
|
57
|
+
// Runs a custom tool's code with the call's input. A thrown error is returned as an error
|
|
58
|
+
// result, so the agent sees what went wrong.
|
|
59
|
+
export async function runTool(tool, input) {
|
|
60
|
+
try {
|
|
61
|
+
const value = await loadTool(tool)(input);
|
|
62
|
+
return { text: typeof value === "string" ? value : (JSON.stringify(value) ?? "") };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { text: `Error: ${err?.message ?? err}`, isError: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
// Runs an interactive chat in the terminal until the user quits or the session ends.
|
|
57
|
-
|
|
69
|
+
// `tools` maps custom tool names to their tools/ files; their code runs here when called.
|
|
70
|
+
export async function chat(client, session, { agentName, tools = {}, input = process.stdin, output = process.stdout }) {
|
|
58
71
|
const rl = createInterface({ input, output, terminal: input.isTTY });
|
|
59
72
|
const lines = rl[Symbol.asyncIterator]();
|
|
60
73
|
let running = false;
|
|
@@ -80,6 +93,11 @@ export async function chat(client, session, { agentName, input = process.stdin,
|
|
|
80
93
|
return { result: "deny", deny_message: reason && !/^(n|no)$/i.test(reason) ? reason : undefined };
|
|
81
94
|
},
|
|
82
95
|
async customToolResult(event) {
|
|
96
|
+
if (tools[event.name]) {
|
|
97
|
+
const result = await runTool(tools[event.name], event.input);
|
|
98
|
+
if (result.isError) output.write(`${red(` ↳ ${truncate(result.text.split("\n")[0])}`)}\n`);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
83
101
|
output.write(dim(`${event.name} is a custom tool, so you supply its result.\n`));
|
|
84
102
|
output.write(dim(`input: ${JSON.stringify(event.input)}\n`));
|
|
85
103
|
return (await ask(bold("result> "))) ?? "The user didn't provide a result.";
|
|
@@ -131,5 +149,5 @@ export async function chat(client, session, { agentName, input = process.stdin,
|
|
|
131
149
|
conversation.close();
|
|
132
150
|
rl.close();
|
|
133
151
|
}
|
|
134
|
-
if (!ended) output.write(dim(`Resume this conversation with: agentschema run --
|
|
152
|
+
if (!ended) output.write(dim(`Resume this conversation with: agentschema run --resume ${session.id}\n`));
|
|
135
153
|
}
|
package/src/check.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { AGENT_FILE, ENV_FILE, readInstance } from "./instance.js";
|
|
2
|
+
import { TOOLS_DIR, checkTools, readTools } from "./tools.js";
|
|
2
3
|
|
|
3
4
|
const AGENT_FIELDS = new Set([
|
|
4
|
-
"name", "model", "description", "
|
|
5
|
+
"name", "model", "description", "mcp_servers", "skills",
|
|
5
6
|
"multiagent", "metadata", "execution_identity",
|
|
6
7
|
]);
|
|
7
8
|
const ENV_FIELDS = new Set(["name", "config", "description", "metadata", "scope"]);
|
|
@@ -20,18 +21,21 @@ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
|
20
21
|
// Returns { errors, warnings, instance }; instance is null when the files can't be read.
|
|
21
22
|
export function check(dir) {
|
|
22
23
|
const { agent, env, errors } = readInstance(dir);
|
|
24
|
+
const { tools, errors: toolErrors } = readTools(dir);
|
|
25
|
+
errors.push(...toolErrors);
|
|
23
26
|
const warnings = [];
|
|
24
27
|
|
|
25
28
|
if (agent) checkAgent(agent, errors, warnings);
|
|
26
29
|
if (env) checkEnv(env, errors, warnings);
|
|
27
|
-
|
|
30
|
+
checkTools(tools, errors);
|
|
31
|
+
for (const [file, parsed] of [[AGENT_FILE, agent], [ENV_FILE, env], ...tools.map((t) => [t.file, t])]) {
|
|
28
32
|
if (!parsed) continue;
|
|
29
33
|
for (const [pattern, what] of SECRET_PATTERNS) {
|
|
30
|
-
if (pattern.test(parsed.raw)) errors.push(`${file}: looks like it contains ${what}; keep secrets
|
|
34
|
+
if (pattern.test(parsed.raw)) errors.push(`${file}: looks like it contains ${what}; keep secrets out of these files`);
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
return { errors, warnings, instance: agent && env ? { agent, env } : null };
|
|
38
|
+
return { errors, warnings, instance: agent && env ? { agent, env, tools } : null };
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function checkAgent({ data, body }, errors, warnings) {
|
|
@@ -53,7 +57,9 @@ function checkAgent({ data, body }, errors, warnings) {
|
|
|
53
57
|
else if (data.description.length > 2048) err("`description` must be at most 2048 characters");
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
|
|
60
|
+
if ("tools" in data) err(`\`tools\` is not allowed in frontmatter; define each tool as a file in ${TOOLS_DIR}/`);
|
|
61
|
+
|
|
62
|
+
for (const [field, max] of [["mcp_servers", 20], ["skills", 500]]) {
|
|
57
63
|
if (data[field] === undefined) continue;
|
|
58
64
|
if (!Array.isArray(data[field])) err(`\`${field}\` must be a list`);
|
|
59
65
|
else if (data[field].length > max) err(`\`${field}\` has ${data[field].length} entries; the limit is ${max}`);
|
|
@@ -62,7 +68,7 @@ function checkAgent({ data, body }, errors, warnings) {
|
|
|
62
68
|
checkMetadata(data.metadata, 16, err);
|
|
63
69
|
|
|
64
70
|
for (const key of Object.keys(data)) {
|
|
65
|
-
if (!AGENT_FIELDS.has(key) && key !== "system") {
|
|
71
|
+
if (!AGENT_FIELDS.has(key) && key !== "system" && key !== "tools") {
|
|
66
72
|
warnings.push(`${AGENT_FILE}: unknown field \`${key}\` will be sent to the API as written`);
|
|
67
73
|
}
|
|
68
74
|
}
|
package/src/delete.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class DeleteError extends Error {}
|
|
2
|
+
|
|
3
|
+
// Agents can't be deleted, only archived: an archived agent drops out of `list`, and
|
|
4
|
+
// there's no unarchive. `confirm(question)` resolves to true to go ahead.
|
|
5
|
+
export async function deleteAgent(client, agentId, { confirm, log }) {
|
|
6
|
+
const agent = await client.beta.agents.retrieve(agentId);
|
|
7
|
+
if (agent.archived_at) throw new DeleteError(`${agentId} is already archived`);
|
|
8
|
+
const ok = await confirm(`Archive agent "${agent.name}" (${agentId})? It can't be undone.`);
|
|
9
|
+
if (!ok) return log("Nothing changed.");
|
|
10
|
+
await client.beta.agents.archive(agentId);
|
|
11
|
+
log(`Archived ${agentId}.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Deletes a session and its event history for good.
|
|
15
|
+
export async function deleteSession(client, sessionId, { confirm, log }) {
|
|
16
|
+
const session = await client.beta.sessions.retrieve(sessionId);
|
|
17
|
+
if (session.status === "running") {
|
|
18
|
+
throw new DeleteError(`${sessionId} is running; wait for it to go idle, or interrupt it, then try again`);
|
|
19
|
+
}
|
|
20
|
+
const label = session.title ? `"${session.title}" (${sessionId})` : sessionId;
|
|
21
|
+
const ok = await confirm(`Delete session ${label} and its history? It can't be undone.`);
|
|
22
|
+
if (!ok) return log("Nothing changed.");
|
|
23
|
+
await client.beta.sessions.delete(sessionId);
|
|
24
|
+
log(`Deleted ${sessionId}.`);
|
|
25
|
+
}
|
package/src/deploy.js
CHANGED
|
@@ -21,8 +21,8 @@ export async function deploy(dir, { client, dryRun = false, force = false, log =
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const steps = [
|
|
24
|
-
{ file: ENV_FILE, kind: "environment", body: envBody(instance
|
|
25
|
-
{ file: AGENT_FILE, kind: "agent", body: agentBody(instance
|
|
24
|
+
{ file: ENV_FILE, kind: "environment", body: envBody(instance) },
|
|
25
|
+
{ file: AGENT_FILE, kind: "agent", body: agentBody(instance) },
|
|
26
26
|
];
|
|
27
27
|
for (const step of steps) {
|
|
28
28
|
step.key = `./${step.file}`;
|
package/src/init.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
|
+
import { createInterface, emitKeypressEvents } from "node:readline";
|
|
4
|
+
import { stringify } from "yaml";
|
|
5
|
+
import { AGENT_FILE, ENV_FILE } from "./instance.js";
|
|
6
|
+
import { BUILT_IN_DIR, PREBUILT_TOOLS, TOOLSET, TOOLS_DIR } from "./tools.js";
|
|
7
|
+
|
|
8
|
+
export class InitError extends Error {}
|
|
9
|
+
|
|
10
|
+
export const MODELS = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"];
|
|
11
|
+
export const NETWORKING = ["limited", "unrestricted"];
|
|
12
|
+
|
|
13
|
+
// The answers used when a question is skipped (--yes, or input ends early).
|
|
14
|
+
export function defaults(dir) {
|
|
15
|
+
const slug = basename(resolve(dir)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent";
|
|
16
|
+
return {
|
|
17
|
+
name: slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" "),
|
|
18
|
+
model: MODELS[0],
|
|
19
|
+
system: "You are a helpful assistant.",
|
|
20
|
+
tools: true,
|
|
21
|
+
environment: slug,
|
|
22
|
+
networking: "limited",
|
|
23
|
+
packageManagers: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns { path: text } for a set of answers: agent.md, env.md, and a tools/built-in/ file
|
|
28
|
+
// for each pre-built tool when the agent gets them.
|
|
29
|
+
export function scaffold(answers) {
|
|
30
|
+
const agent = { name: answers.name, model: answers.model };
|
|
31
|
+
|
|
32
|
+
const networking = { type: answers.networking };
|
|
33
|
+
if (answers.networking === "limited") networking.allow_package_managers = answers.packageManagers;
|
|
34
|
+
const env = { name: answers.environment, config: { type: "cloud", networking } };
|
|
35
|
+
|
|
36
|
+
const access = answers.networking === "unrestricted"
|
|
37
|
+
? "unrestricted network access"
|
|
38
|
+
: answers.packageManagers ? "limited networking and package manager access" : "limited networking";
|
|
39
|
+
const files = {
|
|
40
|
+
[AGENT_FILE]: `---\n${stringify(agent)}---\n\n${answers.system.trim()}\n`,
|
|
41
|
+
[ENV_FILE]: `---\n${stringify(env)}---\nCloud container with ${access}.\n`,
|
|
42
|
+
};
|
|
43
|
+
if (answers.tools) {
|
|
44
|
+
for (const name of PREBUILT_TOOLS) files[`${TOOLS_DIR}/${BUILT_IN_DIR}/${name}.md`] = `---\ntype: ${TOOLSET}\n---\n`;
|
|
45
|
+
}
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Asks the setup questions, then writes agent.md and env.md into a new folder `dir`.
|
|
50
|
+
export async function init(dir, { yes = false, input = process.stdin, output = process.stdout } = {}) {
|
|
51
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) {
|
|
52
|
+
throw new InitError(`${dir} already exists and isn't empty`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const answers = defaults(dir);
|
|
56
|
+
if (!yes) await ask(answers, input, output);
|
|
57
|
+
|
|
58
|
+
const files = scaffold(answers);
|
|
59
|
+
for (const [file, text] of Object.entries(files)) {
|
|
60
|
+
mkdirSync(dirname(resolve(dir, file)), { recursive: true });
|
|
61
|
+
writeFileSync(resolve(dir, file), text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tools = answers.tools ? `, and ${dir}/${TOOLS_DIR}/${BUILT_IN_DIR}/ with the pre-built tools (delete a file to disable that tool)` : "";
|
|
65
|
+
output.write(`\nCreated ${dir}/${AGENT_FILE} and ${dir}/${ENV_FILE}${tools}.\n\nNext:\n`);
|
|
66
|
+
output.write(` agentschema deploy ${dir}\n agentschema run ${dir}\n`);
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Fills in `answers` from the user's replies; an empty reply keeps the default shown.
|
|
71
|
+
async function ask(answers, input, output) {
|
|
72
|
+
const io = input.isTTY ? terminalPrompts(input, output) : linePrompts(input, output);
|
|
73
|
+
const confirm = async (question, fallback) => {
|
|
74
|
+
while (true) {
|
|
75
|
+
const reply = (await io.text(`${question} [y/n]`, fallback ? "y" : "n")).toLowerCase();
|
|
76
|
+
if (/^(y|yes)$/.test(reply)) return true;
|
|
77
|
+
if (/^(n|no)$/.test(reply)) return false;
|
|
78
|
+
output.write("Answer y or n\n");
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
answers.name = await io.text("Agent name?", answers.name);
|
|
84
|
+
answers.model = await io.select("Model?", MODELS, answers.model);
|
|
85
|
+
answers.system = await io.text("System prompt?", answers.system);
|
|
86
|
+
answers.tools = await confirm("Give it the built-in tools (bash, files, web)?", answers.tools);
|
|
87
|
+
answers.environment = await io.text("Environment name?", answers.environment);
|
|
88
|
+
answers.networking = await io.select("Networking?", NETWORKING, answers.networking);
|
|
89
|
+
if (answers.networking === "limited") {
|
|
90
|
+
answers.packageManagers = await confirm("Allow package managers (npm, pip, …)?", answers.packageManagers);
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
io.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Prompts for piped input: one answer per line, and choices picked by name or number.
|
|
98
|
+
function linePrompts(input, output) {
|
|
99
|
+
const rl = createInterface({ input, output, terminal: false });
|
|
100
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
101
|
+
let ended = false;
|
|
102
|
+
|
|
103
|
+
const text = async (question, fallback) => {
|
|
104
|
+
if (ended) return fallback;
|
|
105
|
+
output.write(`${question} (${fallback}) `);
|
|
106
|
+
const { value, done } = await lines.next();
|
|
107
|
+
if (done) {
|
|
108
|
+
ended = true;
|
|
109
|
+
output.write("\n");
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
return value.trim() || fallback;
|
|
113
|
+
};
|
|
114
|
+
const select = async (question, choices, fallback) => {
|
|
115
|
+
const listed = choices.map((c, i) => `${i + 1}) ${c}`).join(" ");
|
|
116
|
+
while (true) {
|
|
117
|
+
const reply = await text(`${question} ${listed}`, String(choices.indexOf(fallback) + 1));
|
|
118
|
+
const picked = choices[Number(reply) - 1] ?? choices.find((c) => c === reply);
|
|
119
|
+
if (picked) return picked;
|
|
120
|
+
output.write(`Pick one of: ${choices.join(", ")}\n`);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
return { text, select, close: () => rl.close() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ESC = "\x1b[";
|
|
127
|
+
const cyan = (s) => `${ESC}36m${s}${ESC}0m`;
|
|
128
|
+
const dim = (s) => `${ESC}2m${s}${ESC}0m`;
|
|
129
|
+
|
|
130
|
+
// Prompts for a terminal: choices are picked with the arrow keys and Enter.
|
|
131
|
+
function terminalPrompts(input, output) {
|
|
132
|
+
const cancelled = () => new InitError("cancelled");
|
|
133
|
+
|
|
134
|
+
// A fresh readline per question, so it doesn't fight the raw-mode select for the arrow keys.
|
|
135
|
+
const text = (question, fallback) =>
|
|
136
|
+
new Promise((resolve, reject) => {
|
|
137
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
138
|
+
rl.on("SIGINT", () => {
|
|
139
|
+
rl.close();
|
|
140
|
+
output.write("\n");
|
|
141
|
+
reject(cancelled());
|
|
142
|
+
});
|
|
143
|
+
rl.question(`${question} ${dim(`(${fallback})`)} `, (reply) => {
|
|
144
|
+
rl.close();
|
|
145
|
+
resolve(reply.trim() || fallback);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const select = (question, choices, fallback) =>
|
|
150
|
+
new Promise((resolve, reject) => {
|
|
151
|
+
let index = Math.max(0, choices.indexOf(fallback));
|
|
152
|
+
const render = (first) => {
|
|
153
|
+
if (!first) output.write(`${ESC}${choices.length}A`);
|
|
154
|
+
for (const [i, choice] of choices.entries()) {
|
|
155
|
+
output.write(`${ESC}2K${i === index ? cyan(`❯ ${choice}`) : ` ${choice}`}\n`);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const finish = (err) => {
|
|
159
|
+
input.off("keypress", onKey);
|
|
160
|
+
input.setRawMode(false);
|
|
161
|
+
input.pause();
|
|
162
|
+
// Collapse the list to one line with the answer.
|
|
163
|
+
output.write(`${ESC}${choices.length + 1}A${ESC}J${ESC}?25h`);
|
|
164
|
+
output.write(`${question} ${err ? dim("cancelled") : cyan(choices[index])}\n`);
|
|
165
|
+
err ? reject(err) : resolve(choices[index]);
|
|
166
|
+
};
|
|
167
|
+
const onKey = (str, key = {}) => {
|
|
168
|
+
if (key.ctrl && key.name === "c") return finish(cancelled());
|
|
169
|
+
if (key.name === "return" || key.name === "enter") return finish();
|
|
170
|
+
if (key.name === "up" || key.name === "k") index = (index - 1 + choices.length) % choices.length;
|
|
171
|
+
else if (key.name === "down" || key.name === "j" || key.name === "tab") index = (index + 1) % choices.length;
|
|
172
|
+
else if (/^[1-9]$/.test(str ?? "") && Number(str) <= choices.length) index = Number(str) - 1;
|
|
173
|
+
else return;
|
|
174
|
+
render(false);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
emitKeypressEvents(input);
|
|
178
|
+
input.setRawMode(true);
|
|
179
|
+
input.resume();
|
|
180
|
+
output.write(`${question} ${dim("(↑/↓, Enter)")}\n${ESC}?25l`);
|
|
181
|
+
render(true);
|
|
182
|
+
input.on("keypress", onKey);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return { text, select, close() {} };
|
|
186
|
+
}
|
package/src/instance.js
CHANGED
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { FileError, parseMarkdown } from "./markdown.js";
|
|
4
|
+
import { compileTools } from "./tools.js";
|
|
4
5
|
|
|
5
6
|
export const AGENT_FILE = "agent.md";
|
|
6
7
|
export const ENV_FILE = "env.md";
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
// Splits a Markdown file into YAML frontmatter (between `---` fences) and body.
|
|
11
|
-
export function parseMarkdown(text, file) {
|
|
12
|
-
const normalized = text.replace(/^/, "").replace(/\r\n/g, "\n");
|
|
13
|
-
if (!normalized.startsWith("---\n")) {
|
|
14
|
-
throw new FileError(`${file}: must start with YAML frontmatter between --- lines`);
|
|
15
|
-
}
|
|
16
|
-
const closing = /^---[ \t]*$/m.exec(normalized.slice(4));
|
|
17
|
-
if (!closing) throw new FileError(`${file}: frontmatter is not closed with a --- line`);
|
|
18
|
-
const end = 4 + closing.index;
|
|
19
|
-
const afterFence = normalized.slice(end + closing[0].length);
|
|
20
|
-
|
|
21
|
-
const doc = parseDocument(normalized.slice(4, end));
|
|
22
|
-
if (doc.errors.length > 0) {
|
|
23
|
-
// The YAML library counts lines from the start of the frontmatter; +1 for the opening fence.
|
|
24
|
-
const err = doc.errors[0];
|
|
25
|
-
const line = err.linePos?.[0]?.line;
|
|
26
|
-
const where = line ? `:${line + 1}` : "";
|
|
27
|
-
throw new FileError(`${file}${where}: invalid YAML: ${err.message.split("\n")[0]}`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const data = doc.toJS() ?? {};
|
|
31
|
-
if (typeof data !== "object" || Array.isArray(data)) {
|
|
32
|
-
throw new FileError(`${file}: frontmatter must be a YAML mapping (key: value pairs)`);
|
|
33
|
-
}
|
|
34
|
-
return { data, body: afterFence.trim(), raw: text };
|
|
35
|
-
}
|
|
9
|
+
export { FileError, parseMarkdown };
|
|
36
10
|
|
|
37
11
|
// Reads agent.md and env.md from an instance folder.
|
|
38
12
|
// Returns { agent, env, errors }; a file that can't be read is null and has an entry in errors.
|
|
@@ -67,12 +41,13 @@ export function readInstance(dir) {
|
|
|
67
41
|
}
|
|
68
42
|
|
|
69
43
|
// The bodies sent to POST /v1/agents and POST /v1/environments.
|
|
70
|
-
|
|
71
|
-
|
|
44
|
+
// The agent's tools come from tools/, compiled into the API's `tools` list.
|
|
45
|
+
export function agentBody({ agent, tools }) {
|
|
46
|
+
const body = { ...agent.data, tools: compileTools(tools) };
|
|
72
47
|
if (agent.body) body.system = agent.body;
|
|
73
48
|
return body;
|
|
74
49
|
}
|
|
75
50
|
|
|
76
|
-
export function envBody(env) {
|
|
51
|
+
export function envBody({ env }) {
|
|
77
52
|
return { ...env.data };
|
|
78
53
|
}
|
package/src/list.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Lists the agents and sessions in the workspace the credentials belong to.
|
|
2
|
+
|
|
3
|
+
export async function listAgents(client) {
|
|
4
|
+
const rows = [];
|
|
5
|
+
for await (const agent of client.beta.agents.list()) {
|
|
6
|
+
rows.push({
|
|
7
|
+
name: agent.name,
|
|
8
|
+
id: agent.id,
|
|
9
|
+
version: String(agent.version),
|
|
10
|
+
model: agent.model?.id ?? "",
|
|
11
|
+
updated: formatDate(agent.updated_at),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return table(rows, { name: "NAME", id: "ID", version: "VERSION", model: "MODEL", updated: "UPDATED" }, "No agents.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function listSessions(client) {
|
|
18
|
+
const rows = [];
|
|
19
|
+
// Newest first, the API's default order.
|
|
20
|
+
for await (const session of client.beta.sessions.list()) {
|
|
21
|
+
rows.push({
|
|
22
|
+
id: session.id,
|
|
23
|
+
status: session.status,
|
|
24
|
+
agent: session.agent ? `${session.agent.name} v${session.agent.version}` : "",
|
|
25
|
+
title: truncate(session.title ?? "", 40),
|
|
26
|
+
created: formatDate(session.created_at),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return table(rows, { id: "ID", status: "STATUS", agent: "AGENT", title: "TITLE", created: "CREATED" }, "No sessions.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Lines of a plain-text table, with columns padded to their widest value.
|
|
33
|
+
function table(rows, headers, empty) {
|
|
34
|
+
if (rows.length === 0) return [empty];
|
|
35
|
+
const keys = Object.keys(headers);
|
|
36
|
+
const widths = keys.map((k) => Math.max(headers[k].length, ...rows.map((r) => r[k].length)));
|
|
37
|
+
const line = (r) => keys.map((k, i) => (i === keys.length - 1 ? r[k] : r[k].padEnd(widths[i]))).join(" ").trimEnd();
|
|
38
|
+
return [line(headers), ...rows.map(line)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// "2026-11-03 14:02" in local time.
|
|
42
|
+
function formatDate(iso) {
|
|
43
|
+
if (!iso) return "";
|
|
44
|
+
const d = new Date(iso);
|
|
45
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
46
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseDocument } from "yaml";
|
|
2
|
+
|
|
3
|
+
export class FileError extends Error {}
|
|
4
|
+
|
|
5
|
+
// Splits a Markdown file into YAML frontmatter (between `---` fences) and body.
|
|
6
|
+
export function parseMarkdown(text, file) {
|
|
7
|
+
const normalized = text.replace(/^/, "").replace(/\r\n/g, "\n");
|
|
8
|
+
if (!normalized.startsWith("---\n")) {
|
|
9
|
+
throw new FileError(`${file}: must start with YAML frontmatter between --- lines`);
|
|
10
|
+
}
|
|
11
|
+
const closing = /^---[ \t]*$/m.exec(normalized.slice(4));
|
|
12
|
+
if (!closing) throw new FileError(`${file}: frontmatter is not closed with a --- line`);
|
|
13
|
+
const end = 4 + closing.index;
|
|
14
|
+
const afterFence = normalized.slice(end + closing[0].length);
|
|
15
|
+
|
|
16
|
+
const doc = parseDocument(normalized.slice(4, end));
|
|
17
|
+
if (doc.errors.length > 0) {
|
|
18
|
+
// The YAML library counts lines from the start of the frontmatter; +1 for the opening fence.
|
|
19
|
+
const err = doc.errors[0];
|
|
20
|
+
const line = err.linePos?.[0]?.line;
|
|
21
|
+
const where = line ? `:${line + 1}` : "";
|
|
22
|
+
throw new FileError(`${file}${where}: invalid YAML: ${err.message.split("\n")[0]}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const data = doc.toJS() ?? {};
|
|
26
|
+
if (typeof data !== "object" || Array.isArray(data)) {
|
|
27
|
+
throw new FileError(`${file}: frontmatter must be a YAML mapping (key: value pairs)`);
|
|
28
|
+
}
|
|
29
|
+
return { data, body: afterFence.trim(), raw: text };
|
|
30
|
+
}
|
package/src/session.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { check } from "./check.js";
|
|
2
2
|
import { AGENT_FILE, ENV_FILE, agentBody, envBody } from "./instance.js";
|
|
3
3
|
import { LOCK_FILE, hashBody, readLock } from "./lock.js";
|
|
4
|
+
import { TOOLS_DIR } from "./tools.js";
|
|
4
5
|
|
|
5
6
|
export class SessionError extends Error {}
|
|
6
7
|
|
|
7
8
|
// Starts a session on the agent and environment recorded in claude-lock.json, or resumes `sessionId`.
|
|
8
9
|
// Returns { session, notes }: notes are warnings worth showing, such as files changed since the last deploy.
|
|
9
|
-
export async function openSession(dir, { client, sessionId
|
|
10
|
+
export async function openSession(dir, { client, sessionId }) {
|
|
10
11
|
if (sessionId) {
|
|
11
12
|
const session = await client.beta.sessions.retrieve(sessionId);
|
|
12
13
|
if (session.status === "terminated") throw new SessionError(`session ${sessionId} has terminated`);
|
|
@@ -21,10 +22,10 @@ export async function openSession(dir, { client, sessionId, vaultIds = [], title
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const notes = [];
|
|
24
|
-
const { instance } = check(dir);
|
|
25
|
-
if (instance) {
|
|
26
|
-
if (hashBody(agentBody(instance
|
|
27
|
-
if (hashBody(envBody(instance
|
|
25
|
+
const { instance, errors } = check(dir);
|
|
26
|
+
if (instance && errors.length === 0) {
|
|
27
|
+
if (hashBody(agentBody(instance)) !== agent.hash) notes.push(`${AGENT_FILE} or ${TOOLS_DIR}/ has changed since the last deploy`);
|
|
28
|
+
if (hashBody(envBody(instance)) !== env.hash) notes.push(`${ENV_FILE} has changed since the last deploy`);
|
|
28
29
|
if (notes.length > 0) notes.push(`this session uses the deployed version; run agentschema deploy to pick up the changes`);
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -33,16 +34,15 @@ export async function openSession(dir, { client, sessionId, vaultIds = [], title
|
|
|
33
34
|
agent: { type: "agent", id: agent.id, version: Number(agent.version) },
|
|
34
35
|
environment_id: env.id,
|
|
35
36
|
};
|
|
36
|
-
if (vaultIds.length > 0) params.vault_ids = vaultIds;
|
|
37
37
|
const name = instance?.agent.data.name;
|
|
38
|
-
params.title =
|
|
38
|
+
params.title = name ? `${name} (agentschema run)` : "agentschema run";
|
|
39
39
|
return { session: await client.beta.sessions.create(params), notes };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// Drives a session one turn at a time. `ui` answers what only a person can:
|
|
43
43
|
// ui.event(event) show an event
|
|
44
44
|
// ui.confirm(event) -> { result: "allow" | "deny", deny_message? } for a paused tool call
|
|
45
|
-
// ui.customToolResult(event) -> string, the result of a custom tool call
|
|
45
|
+
// ui.customToolResult(event) -> string, or { text, isError }: the result of a custom tool call
|
|
46
46
|
export class Conversation {
|
|
47
47
|
constructor(client, sessionId, ui) {
|
|
48
48
|
this.client = client;
|
|
@@ -109,8 +109,11 @@ export class Conversation {
|
|
|
109
109
|
}
|
|
110
110
|
return null;
|
|
111
111
|
case "agent.custom_tool_use": {
|
|
112
|
-
const
|
|
113
|
-
|
|
112
|
+
const result = await this.ui.customToolResult(event);
|
|
113
|
+
const { text, isError } = typeof result === "string" ? { text: result } : result;
|
|
114
|
+
const reply = { type: "user.custom_tool_result", custom_tool_use_id: event.id, content: [{ type: "text", text }] };
|
|
115
|
+
if (isError) reply.is_error = true;
|
|
116
|
+
await send(reply);
|
|
114
117
|
return null;
|
|
115
118
|
}
|
|
116
119
|
case "session.status_idle":
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { Script, runInThisContext } from "node:vm";
|
|
5
|
+
import { FileError, parseMarkdown } from "./markdown.js";
|
|
6
|
+
|
|
7
|
+
export const TOOLS_DIR = "tools";
|
|
8
|
+
export const BUILT_IN_DIR = "built-in";
|
|
9
|
+
export const TOOLSET = "agent_toolset_20260401";
|
|
10
|
+
export const PREBUILT_TOOLS = ["bash", "edit", "glob", "grep", "read", "web_fetch", "web_search", "write"];
|
|
11
|
+
const TYPES = ["string", "number", "integer", "boolean", "object", "array"];
|
|
12
|
+
|
|
13
|
+
// Reads every .md file under tools/, in any subfolder, sorted by name. Folders are only for
|
|
14
|
+
// organizing: a tool's name is its file name, and its path isn't compiled into anything.
|
|
15
|
+
// A missing tools/ folder means no tools.
|
|
16
|
+
// Returns { tools, errors }; a tool file that can't be parsed has an entry in errors.
|
|
17
|
+
export function readTools(dir) {
|
|
18
|
+
const folder = join(dir, TOOLS_DIR);
|
|
19
|
+
try {
|
|
20
|
+
if (!statSync(folder).isDirectory()) return { tools: [], errors: [`${TOOLS_DIR}: not a directory`] };
|
|
21
|
+
} catch {
|
|
22
|
+
return { tools: [], errors: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const tools = [];
|
|
26
|
+
const errors = [];
|
|
27
|
+
for (const file of markdownFiles(folder, TOOLS_DIR)) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = parseMarkdown(readFileSync(join(dir, file), "utf8"), file);
|
|
30
|
+
const name = basename(file, ".md");
|
|
31
|
+
tools.push({ file, path: resolve(dir, file), name, ...parsed, ...parseSections(parsed.body, file) });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (!(err instanceof FileError)) throw err;
|
|
34
|
+
errors.push(err.message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Names must be unique across folders, since the folders are dropped.
|
|
39
|
+
for (const [name, same] of groupBy(tools, (t) => t.name)) {
|
|
40
|
+
if (same.length > 1) errors.push(`${TOOLS_DIR}: more than one tool is named \`${name}\` (${same.map((t) => t.file).join(", ")})`);
|
|
41
|
+
}
|
|
42
|
+
return { tools: tools.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)), errors };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Paths, relative to the instance folder, of the .md files under `folder`; hidden entries are skipped.
|
|
46
|
+
function markdownFiles(folder, relative) {
|
|
47
|
+
const files = [];
|
|
48
|
+
for (const entry of readdirSync(folder, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
49
|
+
if (entry.name.startsWith(".")) continue;
|
|
50
|
+
const rel = `${relative}/${entry.name}`;
|
|
51
|
+
if (entry.isDirectory()) files.push(...markdownFiles(join(folder, entry.name), rel));
|
|
52
|
+
else if (entry.name.endsWith(".md")) files.push(rel);
|
|
53
|
+
}
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function groupBy(items, key) {
|
|
58
|
+
const groups = new Map();
|
|
59
|
+
for (const item of items) groups.set(key(item), [...(groups.get(key(item)) ?? []), item]);
|
|
60
|
+
return groups;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pulls the `## Input Schema` table and the `## Code` block out of a tool file's body.
|
|
64
|
+
function parseSections(body, file) {
|
|
65
|
+
const sections = {};
|
|
66
|
+
let current = null;
|
|
67
|
+
let fence = null;
|
|
68
|
+
for (const line of body.split("\n")) {
|
|
69
|
+
const marker = /^(```|~~~)/.exec(line)?.[1];
|
|
70
|
+
if (marker && (!fence || fence === marker)) fence = fence ? null : marker;
|
|
71
|
+
const heading = !fence && /^##\s+(.+?)\s*$/.exec(line);
|
|
72
|
+
if (heading) {
|
|
73
|
+
current = heading[1].toLowerCase();
|
|
74
|
+
sections[current] = [];
|
|
75
|
+
} else if (current) {
|
|
76
|
+
sections[current].push(line);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = { params: null, code: null };
|
|
81
|
+
if (sections["input schema"]) result.params = parseTable(sections["input schema"], file);
|
|
82
|
+
if (sections.code) result.code = parseCode(sections.code, file);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Rows of the Name | Type | Description | Required table.
|
|
87
|
+
function parseTable(lines, file) {
|
|
88
|
+
const rows = lines.filter((l) => l.trim().startsWith("|")).map(splitRow);
|
|
89
|
+
if (rows.length === 0) return [];
|
|
90
|
+
const header = rows[0].map((c) => c.toLowerCase());
|
|
91
|
+
const want = ["name", "type", "description", "required"];
|
|
92
|
+
if (want.some((c, i) => header[i] !== c)) {
|
|
93
|
+
throw new FileError(`${file}: the Input Schema table's columns must be Name | Type | Description | Required`);
|
|
94
|
+
}
|
|
95
|
+
return rows.slice(2).map(([name, type, description, required]) => ({ name, type, description, required }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function splitRow(line) {
|
|
99
|
+
const cells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split(/(?<!\\)\|/);
|
|
100
|
+
return cells.map((c) => c.trim().replace(/\\\|/g, "|").replace(/^`(.*)`$/, "$1"));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseCode(lines, file) {
|
|
104
|
+
const text = lines.join("\n");
|
|
105
|
+
const blocks = [...text.matchAll(/^(```|~~~)[ \t]*([\w-]*)[^\n]*\n([\s\S]*?)^\1[ \t]*$/gm)];
|
|
106
|
+
if (blocks.length !== 1) throw new FileError(`${file}: the Code section must hold exactly one fenced code block`);
|
|
107
|
+
const [, , lang, source] = blocks[0];
|
|
108
|
+
return { lang: lang.toLowerCase(), source };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Errors and warnings for the tool files, in check's format.
|
|
112
|
+
export function checkTools(tools, errors) {
|
|
113
|
+
const customs = tools.filter((t) => t.data.type === "custom");
|
|
114
|
+
if (customs.length + (customs.length < tools.length ? 1 : 0) > 256) errors.push(`${TOOLS_DIR}: more than 256 tools`);
|
|
115
|
+
|
|
116
|
+
for (const tool of tools) {
|
|
117
|
+
const err = (msg) => errors.push(`${tool.file}: ${msg}`);
|
|
118
|
+
const { type } = tool.data;
|
|
119
|
+
const extra = (allowed) => {
|
|
120
|
+
for (const key of Object.keys(tool.data)) if (!allowed.includes(key)) err(`unknown field \`${key}\``);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (type === TOOLSET) {
|
|
124
|
+
if (!PREBUILT_TOOLS.includes(tool.name)) {
|
|
125
|
+
err(`not a pre-built tool; name the file after one of: ${PREBUILT_TOOLS.join(", ")}`);
|
|
126
|
+
}
|
|
127
|
+
extra(["type"]);
|
|
128
|
+
} else if (type === "custom") {
|
|
129
|
+
checkCustom(tool, err);
|
|
130
|
+
extra(["type", "name", "description"]);
|
|
131
|
+
} else {
|
|
132
|
+
err(`\`type\` must be \`${TOOLSET}\` (a pre-built tool) or \`custom\``);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function checkCustom(tool, err) {
|
|
138
|
+
const { name, description } = tool.data;
|
|
139
|
+
if (name !== tool.name) err(`\`name\` must match the file name (${tool.name})`);
|
|
140
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(tool.name)) err("the name may only use letters, digits, _ and -, up to 64 characters");
|
|
141
|
+
if (PREBUILT_TOOLS.includes(tool.name)) err(`\`${tool.name}\` is a pre-built tool's name; the API rejects custom tools that reuse one`);
|
|
142
|
+
if (/^(mcp__|ant__)/.test(tool.name)) err("custom tool names can't start with mcp__ or ant__");
|
|
143
|
+
if (typeof description !== "string" || description.trim() === "") err("`description` is required");
|
|
144
|
+
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
for (const p of tool.params ?? []) {
|
|
147
|
+
if (!p.name) err("an Input Schema row has no name");
|
|
148
|
+
else if (seen.has(p.name)) err(`input \`${p.name}\` is listed twice`);
|
|
149
|
+
seen.add(p.name);
|
|
150
|
+
if (!TYPES.includes(p.type?.replace(/\[\]$/, ""))) {
|
|
151
|
+
err(`input \`${p.name}\` has type \`${p.type}\`; use ${TYPES.join(", ")}, or one of them followed by []`);
|
|
152
|
+
}
|
|
153
|
+
if (!["true", "false"].includes(p.required?.toLowerCase())) err(`input \`${p.name}\`: Required must be true or false`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!tool.code) return err("a `## Code` section with a JavaScript code block is required");
|
|
157
|
+
if (!["javascript", "js"].includes(tool.code.lang)) err("the code must be JavaScript (```javascript)");
|
|
158
|
+
else {
|
|
159
|
+
try {
|
|
160
|
+
new Script(wrap(tool.code.source), { filename: tool.file });
|
|
161
|
+
} catch (e) {
|
|
162
|
+
err(`the code doesn't parse: ${e.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The tools list sent to the API: one toolset entry that enables only the pre-built tools
|
|
168
|
+
// with a file (so deleting a file disables the tool), then the custom tools, sorted by name.
|
|
169
|
+
export function compileTools(tools) {
|
|
170
|
+
const compiled = [];
|
|
171
|
+
const prebuilt = tools.filter((t) => t.data.type === TOOLSET).map((t) => t.name).sort();
|
|
172
|
+
if (prebuilt.length > 0) {
|
|
173
|
+
compiled.push({
|
|
174
|
+
type: TOOLSET,
|
|
175
|
+
default_config: { enabled: false },
|
|
176
|
+
configs: prebuilt.map((name) => ({ name, enabled: true })),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
for (const tool of tools.filter((t) => t.data.type === "custom").sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
180
|
+
compiled.push({ type: "custom", name: tool.name, description: tool.data.description, input_schema: inputSchema(tool.params ?? []) });
|
|
181
|
+
}
|
|
182
|
+
return compiled;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function inputSchema(params) {
|
|
186
|
+
const properties = {};
|
|
187
|
+
for (const p of params) {
|
|
188
|
+
const list = p.type.endsWith("[]");
|
|
189
|
+
const type = p.type.replace(/\[\]$/, "");
|
|
190
|
+
const prop = list ? { type: "array", items: { type } } : { type };
|
|
191
|
+
if (p.description) prop.description = p.description;
|
|
192
|
+
properties[p.name] = prop;
|
|
193
|
+
}
|
|
194
|
+
const required = params.filter((p) => p.required.toLowerCase() === "true").map((p) => p.name);
|
|
195
|
+
return required.length > 0 ? { type: "object", properties, required } : { type: "object", properties };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Loads a custom tool's code as a CommonJS module whose module.exports is the function.
|
|
199
|
+
// require() resolves relative to the tool's file, so the agent folder's node_modules work.
|
|
200
|
+
export function loadTool(tool) {
|
|
201
|
+
const module = { exports: {} };
|
|
202
|
+
const fn = runInThisContext(wrap(tool.code.source), { filename: tool.path });
|
|
203
|
+
fn(module.exports, createRequire(tool.path), module, tool.path, resolve(tool.path, ".."));
|
|
204
|
+
if (typeof module.exports !== "function") {
|
|
205
|
+
throw new Error(`${tool.file}: module.exports must be a function`);
|
|
206
|
+
}
|
|
207
|
+
return module.exports;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const wrap = (source) => `(function (exports, require, module, __filename, __dirname) {${source}\n})`;
|