mirab 0.0.0 → 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/LICENSE +201 -0
- package/README.md +223 -3
- package/package.json +56 -7
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- package/types/tools.d.ts +2 -0
- package/index.js +0 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `mirab init`: make an agent folder. It never writes a .env and never
|
|
2
|
+
// overwrites a file; model access comes from harar, not from the project.
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { basename, join, resolve } from 'node:path'
|
|
7
|
+
import { MODEL, agentNameProblem } from './define'
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_MODEL = 'anthropic/claude-sonnet-5-5'
|
|
10
|
+
|
|
11
|
+
/** The folders an agent may fill, one capability each. */
|
|
12
|
+
export const AGENT_FOLDERS = [
|
|
13
|
+
'tools',
|
|
14
|
+
'skills',
|
|
15
|
+
'subagents',
|
|
16
|
+
'schedules',
|
|
17
|
+
'channels',
|
|
18
|
+
] as const
|
|
19
|
+
|
|
20
|
+
export interface InitResult {
|
|
21
|
+
/** Files written, relative to the project. */
|
|
22
|
+
written: string[]
|
|
23
|
+
/** Something init skipped and the person should know about. */
|
|
24
|
+
notes: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface InitOptions {
|
|
28
|
+
/** Where the project goes; made if missing. */
|
|
29
|
+
directory: string
|
|
30
|
+
/** The agent's harar name, `<org>.agent.<name>`. */
|
|
31
|
+
name: string
|
|
32
|
+
model?: string
|
|
33
|
+
/** Run `git init` (default true). */
|
|
34
|
+
git?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether `directory` can take a new agent: missing, empty, or only dotfiles like .git. */
|
|
38
|
+
export function initProblem(directory: string): string | null {
|
|
39
|
+
if (!existsSync(directory)) return null
|
|
40
|
+
if (!lstatSync(directory).isDirectory()) return `${directory} isn't a folder`
|
|
41
|
+
// lstat, so a dangling symlink named agent counts as taken too.
|
|
42
|
+
if (lstatSync(join(directory, 'agent'), { throwIfNoEntry: false }))
|
|
43
|
+
return `${directory} already has an agent/ folder`
|
|
44
|
+
const taken = readdirSync(directory).filter(
|
|
45
|
+
(entry) => !entry.startsWith('.') && entry !== 'README.md',
|
|
46
|
+
)
|
|
47
|
+
return taken.length
|
|
48
|
+
? `${directory} isn't empty (${taken.slice(0, 3).join(', ')}${taken.length > 3 ? ', …' : ''}); run it in an empty folder`
|
|
49
|
+
: null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The files an agent starts with, by path relative to the project. */
|
|
53
|
+
export function agentFiles(name: string, model = DEFAULT_MODEL) {
|
|
54
|
+
const local = name.split('.').at(-1)!
|
|
55
|
+
return {
|
|
56
|
+
'package.json':
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
{
|
|
59
|
+
name: local,
|
|
60
|
+
private: true,
|
|
61
|
+
type: 'module',
|
|
62
|
+
scripts: { dev: 'mirab dev' },
|
|
63
|
+
// mirab isn't on npm yet; its repository is public.
|
|
64
|
+
dependencies: { mirab: 'github:addiscorp/mirab', zod: '^4.1.12' },
|
|
65
|
+
},
|
|
66
|
+
null,
|
|
67
|
+
2,
|
|
68
|
+
) + '\n',
|
|
69
|
+
// Never commit a key; mirab never reads one from here either.
|
|
70
|
+
'.gitignore': 'node_modules/\n.env\n.env.*\n',
|
|
71
|
+
'agent/agent.ts': `import { defineAgent } from 'mirab'
|
|
72
|
+
|
|
73
|
+
export default defineAgent({
|
|
74
|
+
name: '${name}',
|
|
75
|
+
model: '${model}',
|
|
76
|
+
})
|
|
77
|
+
`,
|
|
78
|
+
'agent/instructions.md': `You are ${local}. Be concise, and use your tools when they help.
|
|
79
|
+
`,
|
|
80
|
+
...Object.fromEntries(
|
|
81
|
+
AGENT_FOLDERS.map((folder) => [`agent/${folder}/.gitkeep`, '']),
|
|
82
|
+
),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Makes the project. Everything is checked before anything is written, so a
|
|
87
|
+
* refusal leaves the folder as it was. */
|
|
88
|
+
export async function initAgent(options: InitOptions): Promise<InitResult> {
|
|
89
|
+
const name = options.name.trim()
|
|
90
|
+
const problem = agentNameProblem(name)
|
|
91
|
+
if (problem) throw new Error(`The agent name ${problem}`)
|
|
92
|
+
const model = options.model ?? DEFAULT_MODEL
|
|
93
|
+
if (!MODEL.test(model))
|
|
94
|
+
throw new Error(
|
|
95
|
+
`The model is provider/model, like ${DEFAULT_MODEL}: ${JSON.stringify(model)}`,
|
|
96
|
+
)
|
|
97
|
+
const directory = resolve(options.directory)
|
|
98
|
+
const taken = initProblem(directory)
|
|
99
|
+
if (taken) throw new Error(taken)
|
|
100
|
+
const files: Record<string, string> = agentFiles(name, model)
|
|
101
|
+
// An existing .gitignore is kept and gets only the lines it lacks.
|
|
102
|
+
const gitignore = join(directory, '.gitignore')
|
|
103
|
+
let ignoreAdd: string | undefined
|
|
104
|
+
if (existsSync(gitignore)) {
|
|
105
|
+
const have = new Set(
|
|
106
|
+
readFileSync(gitignore, 'utf8')
|
|
107
|
+
.split('\n')
|
|
108
|
+
.map((l) => l.trim()),
|
|
109
|
+
)
|
|
110
|
+
const missing = files['.gitignore']
|
|
111
|
+
.split('\n')
|
|
112
|
+
.filter((l) => l && !have.has(l))
|
|
113
|
+
if (missing.length) ignoreAdd = missing.join('\n') + '\n'
|
|
114
|
+
delete files['.gitignore']
|
|
115
|
+
}
|
|
116
|
+
const clash = Object.keys(files).find((path) =>
|
|
117
|
+
lstatSync(join(directory, path), { throwIfNoEntry: false }),
|
|
118
|
+
)
|
|
119
|
+
if (clash)
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${join(directory, clash)} already exists; nothing was written`,
|
|
122
|
+
)
|
|
123
|
+
await mkdir(directory, { recursive: true })
|
|
124
|
+
const result: InitResult = { written: [], notes: [] }
|
|
125
|
+
for (const [path, contents] of Object.entries(files)) {
|
|
126
|
+
const target = join(directory, path)
|
|
127
|
+
await mkdir(join(target, '..'), { recursive: true })
|
|
128
|
+
// `wx`: never overwrite, even a file that appeared since the check.
|
|
129
|
+
await writeFile(target, contents, { flag: 'wx' })
|
|
130
|
+
result.written.push(path)
|
|
131
|
+
}
|
|
132
|
+
if (ignoreAdd) {
|
|
133
|
+
await appendFile(gitignore, ignoreAdd)
|
|
134
|
+
result.written.push('.gitignore (added lines)')
|
|
135
|
+
}
|
|
136
|
+
if (options.git !== false) {
|
|
137
|
+
// Never a repository inside another one.
|
|
138
|
+
const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
139
|
+
cwd: directory,
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
})
|
|
142
|
+
if (inside.status === 0 && inside.stdout.trim() === 'true')
|
|
143
|
+
result.notes.push('Already inside a git repository, so no git init.')
|
|
144
|
+
else if (spawnSync('git', ['init', '-q'], { cwd: directory }).status !== 0)
|
|
145
|
+
result.notes.push(
|
|
146
|
+
`git init didn't work in ${basename(directory)}; the agent is made, run it yourself.`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The agent manifest (docs/architecture.md section 5a): plain data about an
|
|
2
|
+
// agent, and the one thing mirab reads about it. agent/agent.ts is one way to
|
|
3
|
+
// write it (a Nix flake will be the other); both end up checked by
|
|
4
|
+
// manifestSchema, so there is one definition of what an agent is.
|
|
5
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
6
|
+
import { readFile } from 'node:fs/promises'
|
|
7
|
+
import { basename, join, resolve } from 'node:path'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import {
|
|
11
|
+
agentSchema,
|
|
12
|
+
commandToolSchema,
|
|
13
|
+
toolInput,
|
|
14
|
+
toolRecord,
|
|
15
|
+
toolNameProblem,
|
|
16
|
+
type Tool,
|
|
17
|
+
} from './define'
|
|
18
|
+
import { registerAgentModules } from './modules'
|
|
19
|
+
|
|
20
|
+
/** A TypeScript tool, `agent/<file>`, run by the agent loop. */
|
|
21
|
+
export const typescriptToolSchema = z.strictObject({
|
|
22
|
+
description: z.string().trim().min(1, 'a tool needs a description'),
|
|
23
|
+
/** Relative to agent/, e.g. `tools/add.ts`. */
|
|
24
|
+
file: z.string().regex(/^tools\/[a-zA-Z0-9_-]{1,64}\.ts$/),
|
|
25
|
+
input: toolInput,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export const manifestSchema = z.strictObject({
|
|
29
|
+
name: agentSchema.shape.name,
|
|
30
|
+
model: agentSchema.shape.model,
|
|
31
|
+
/** The system prompt. */
|
|
32
|
+
instructions: z.string(),
|
|
33
|
+
tools: toolRecord(z.union([typescriptToolSchema, commandToolSchema])),
|
|
34
|
+
packages: z.array(agentSchema.shape.packages.unwrap().element),
|
|
35
|
+
domains: z.array(agentSchema.shape.domains.unwrap().element),
|
|
36
|
+
channels: z.array(agentSchema.shape.channels.unwrap().element),
|
|
37
|
+
gateway: agentSchema.shape.gateway.unwrap().default({}),
|
|
38
|
+
})
|
|
39
|
+
export type Manifest = z.infer<typeof manifestSchema>
|
|
40
|
+
|
|
41
|
+
/** The file as it is now: its URL carries its modification time, so a file
|
|
42
|
+
* edited since the last load is imported again, not served from the cache. */
|
|
43
|
+
function fresh(path: string) {
|
|
44
|
+
return `${pathToFileURL(path).href}?v=${statSync(path).mtimeMs}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Checks manifest data from anywhere (agent.ts, `nix eval --json`). */
|
|
48
|
+
export function parseManifest(data: unknown, from: string): Manifest {
|
|
49
|
+
const parsed = manifestSchema.safeParse(data)
|
|
50
|
+
if (!parsed.success)
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${from}: ${parsed.error.issues.map((i) => `${i.path.join('.') || 'manifest'}: ${i.message}`).join('; ')}`,
|
|
53
|
+
)
|
|
54
|
+
return parsed.data
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LoadedManifest {
|
|
58
|
+
manifest: Manifest
|
|
59
|
+
/** The TypeScript tools as their files export them, by name. */
|
|
60
|
+
typescript: Record<string, Tool<z.ZodType>>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reads `<project>/agent` (agent.ts, instructions.md, tools/*.ts) into a manifest. */
|
|
64
|
+
export async function loadManifest(project: string): Promise<LoadedManifest> {
|
|
65
|
+
registerAgentModules()
|
|
66
|
+
const dir = join(resolve(project), 'agent')
|
|
67
|
+
const definition = join(dir, 'agent.ts')
|
|
68
|
+
if (!existsSync(definition))
|
|
69
|
+
throw new Error(`${definition} is missing; mirab init makes one`)
|
|
70
|
+
const parsed = agentSchema.safeParse(
|
|
71
|
+
(await import(fresh(definition))).default,
|
|
72
|
+
)
|
|
73
|
+
if (!parsed.success)
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${definition}: ${parsed.error.issues.map((i) => i.message).join('; ')}`,
|
|
76
|
+
)
|
|
77
|
+
const agent = parsed.data
|
|
78
|
+
const instructionsPath = join(dir, 'instructions.md')
|
|
79
|
+
const instructions = existsSync(instructionsPath)
|
|
80
|
+
? await readFile(instructionsPath, 'utf8')
|
|
81
|
+
: ''
|
|
82
|
+
// No prototype, so a tool named __proto__ is a tool like any other.
|
|
83
|
+
const tools: Manifest['tools'] = Object.create(null)
|
|
84
|
+
const typescript: LoadedManifest['typescript'] = Object.create(null)
|
|
85
|
+
for (const [name, tool] of Object.entries(agent.tools ?? {}))
|
|
86
|
+
tools[name] = tool
|
|
87
|
+
const toolsDir = join(dir, 'tools')
|
|
88
|
+
if (existsSync(toolsDir))
|
|
89
|
+
for (const file of readdirSync(toolsDir).sort()) {
|
|
90
|
+
if (
|
|
91
|
+
file.startsWith('.') ||
|
|
92
|
+
file.endsWith('.test.ts') ||
|
|
93
|
+
file.endsWith('.d.ts')
|
|
94
|
+
)
|
|
95
|
+
continue
|
|
96
|
+
if (!file.endsWith('.ts'))
|
|
97
|
+
throw new Error(
|
|
98
|
+
`tools/${file}: a tool is a TypeScript file (.ts); move anything else out of tools/`,
|
|
99
|
+
)
|
|
100
|
+
const name = basename(file, '.ts')
|
|
101
|
+
const problem = toolNameProblem(name)
|
|
102
|
+
if (problem) throw new Error(`tools/${file}: ${problem} (its file name)`)
|
|
103
|
+
if (Object.hasOwn(tools, name))
|
|
104
|
+
throw new Error(
|
|
105
|
+
`tools/${file}: agent.ts has a command tool named ${name} too; one name, one tool`,
|
|
106
|
+
)
|
|
107
|
+
const defined = (await import(fresh(join(toolsDir, file)))).default as
|
|
108
|
+
Tool<z.ZodType> | undefined
|
|
109
|
+
if (!defined?.description || !defined.inputSchema || !defined.execute)
|
|
110
|
+
throw new Error(
|
|
111
|
+
`tools/${file} must export default defineTool({ description, inputSchema, execute })`,
|
|
112
|
+
)
|
|
113
|
+
const input = z.toJSONSchema(defined.inputSchema, {
|
|
114
|
+
unrepresentable: 'any',
|
|
115
|
+
})
|
|
116
|
+
if (input.type !== 'object')
|
|
117
|
+
throw new Error(
|
|
118
|
+
`tools/${file}: inputSchema is a z.object({...}), so the model can call it (no transform or union at the top)`,
|
|
119
|
+
)
|
|
120
|
+
typescript[name] = defined
|
|
121
|
+
tools[name] = {
|
|
122
|
+
description: defined.description,
|
|
123
|
+
file: `tools/${file}`,
|
|
124
|
+
input,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const manifest = parseManifest(
|
|
128
|
+
{
|
|
129
|
+
name: agent.name,
|
|
130
|
+
model: agent.model,
|
|
131
|
+
instructions,
|
|
132
|
+
tools,
|
|
133
|
+
packages: agent.packages ?? [],
|
|
134
|
+
domains: agent.domains ?? [],
|
|
135
|
+
channels: agent.channels ?? [],
|
|
136
|
+
gateway: agent.gateway ?? {},
|
|
137
|
+
},
|
|
138
|
+
definition,
|
|
139
|
+
)
|
|
140
|
+
return { manifest, typescript }
|
|
141
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The modules an agent's own files import ('mirab', 'mirab/tools', 'zod'),
|
|
2
|
+
// given by mirab itself: in a VM there is no node_modules, and one zod means
|
|
3
|
+
// a tool's schema is checked by the same zod that made it. They win over a
|
|
4
|
+
// project's own node_modules (its pinned mirab or zod is not used), for the
|
|
5
|
+
// whole process once registered.
|
|
6
|
+
import * as zod from 'zod'
|
|
7
|
+
import * as mirab from '../index'
|
|
8
|
+
import * as tools from '../tools'
|
|
9
|
+
|
|
10
|
+
let registered = false
|
|
11
|
+
|
|
12
|
+
export function registerAgentModules() {
|
|
13
|
+
if (registered) return
|
|
14
|
+
registered = true
|
|
15
|
+
Bun.plugin({
|
|
16
|
+
name: 'mirab-agent-modules',
|
|
17
|
+
setup(build) {
|
|
18
|
+
build.module('mirab', () => ({ exports: mirab, loader: 'object' }))
|
|
19
|
+
build.module('mirab/tools', () => ({ exports: tools, loader: 'object' }))
|
|
20
|
+
build.module('zod', () => ({ exports: zod, loader: 'object' }))
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// The loop that runs inside the agent's VM (docs/architecture.md section 3):
|
|
2
|
+
// it loads the agent folder and runs turns, calling tools as the model asks.
|
|
3
|
+
// Where the model is and what key to send are given by whoever starts it (in
|
|
4
|
+
// the VM: the station's loopback route and a placeholder); the runtime never
|
|
5
|
+
// reads an environment variable or a .env for them.
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { resolve } from 'node:path'
|
|
8
|
+
import { createAnthropic } from '@ai-sdk/anthropic'
|
|
9
|
+
import {
|
|
10
|
+
generateText,
|
|
11
|
+
stepCountIs,
|
|
12
|
+
dynamicTool,
|
|
13
|
+
type LanguageModel,
|
|
14
|
+
type ModelMessage,
|
|
15
|
+
type ToolSet,
|
|
16
|
+
} from 'ai'
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
import type { CommandTool } from './define'
|
|
19
|
+
import { loadManifest, type Manifest } from './manifest'
|
|
20
|
+
|
|
21
|
+
/** Turns stop after this many model calls, so a tool loop can't run forever. */
|
|
22
|
+
export const MAX_STEPS = 20
|
|
23
|
+
/** How long one tool call, and one whole turn, may take. */
|
|
24
|
+
export const TOOL_TIMEOUT_MS = 5 * 60_000
|
|
25
|
+
export const TURN_TIMEOUT_MS = 30 * 60_000
|
|
26
|
+
/** The most a command tool may print, on stdout or stderr. */
|
|
27
|
+
export const COMMAND_OUTPUT_MAX = 1024 * 1024
|
|
28
|
+
|
|
29
|
+
export interface LoadedAgent {
|
|
30
|
+
agent: Manifest
|
|
31
|
+
tools: ToolSet
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Loads `<project>/agent` and makes its manifest's tools callable. */
|
|
35
|
+
export async function loadAgent(project: string): Promise<LoadedAgent> {
|
|
36
|
+
const root = resolve(project)
|
|
37
|
+
const { manifest, typescript } = await loadManifest(root)
|
|
38
|
+
// No prototype, so a tool named __proto__ is a tool like any other.
|
|
39
|
+
const tools: ToolSet = Object.create(null)
|
|
40
|
+
for (const [name, tool] of Object.entries(manifest.tools)) {
|
|
41
|
+
// The model's input is checked against the tool's own schema first.
|
|
42
|
+
tools[name] =
|
|
43
|
+
'file' in tool
|
|
44
|
+
? dynamicTool({
|
|
45
|
+
description: tool.description,
|
|
46
|
+
inputSchema: typescript[name].inputSchema,
|
|
47
|
+
execute: async (input) => typescript[name].execute(input),
|
|
48
|
+
})
|
|
49
|
+
: commandTool(tool, root)
|
|
50
|
+
}
|
|
51
|
+
return { agent: manifest, tools }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A command tool for the model. The command gets the checked input, not
|
|
55
|
+
* what the model sent: keys its schema doesn't list never reach it. */
|
|
56
|
+
function commandTool(tool: CommandTool, root: string) {
|
|
57
|
+
const schema = z.fromJSONSchema(closedSchema(tool.input))
|
|
58
|
+
return dynamicTool({
|
|
59
|
+
description: tool.description,
|
|
60
|
+
inputSchema: schema,
|
|
61
|
+
execute: (input, { abortSignal }) =>
|
|
62
|
+
runCommand(tool, root, schema.parse(input), abortSignal),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A JSON Schema where every object that doesn't say otherwise takes only
|
|
67
|
+
* the properties it lists, as a TypeScript tool's z.object does. */
|
|
68
|
+
export function closedSchema<T>(schema: T, open = false): T {
|
|
69
|
+
if (Array.isArray(schema))
|
|
70
|
+
return schema.map((s) => closedSchema(s, open)) as T
|
|
71
|
+
if (!schema || typeof schema !== 'object') return schema
|
|
72
|
+
const node: Record<string, unknown> = Object.fromEntries(
|
|
73
|
+
Object.entries(schema).map(([k, v]) => [
|
|
74
|
+
k,
|
|
75
|
+
// Values, not schemas, are left as written; each allOf branch is one
|
|
76
|
+
// part of an object whose keys the other branches list.
|
|
77
|
+
VALUE_KEYWORDS.has(k) ? v : closedSchema(v, k === 'allOf'),
|
|
78
|
+
]),
|
|
79
|
+
)
|
|
80
|
+
if (
|
|
81
|
+
!open &&
|
|
82
|
+
node.type === 'object' &&
|
|
83
|
+
!('additionalProperties' in node) &&
|
|
84
|
+
!('patternProperties' in node)
|
|
85
|
+
)
|
|
86
|
+
node.additionalProperties = false
|
|
87
|
+
return node as T
|
|
88
|
+
}
|
|
89
|
+
const VALUE_KEYWORDS = new Set(['const', 'enum', 'default', 'examples'])
|
|
90
|
+
|
|
91
|
+
/** Runs a command tool: input as JSON on stdin, its JSON answer from stdout.
|
|
92
|
+
* No shell, the project as its working directory, and only PATH and HOME
|
|
93
|
+
* from the loop's environment, so a tool never sees what the loop was given.
|
|
94
|
+
* It runs in its own process group: a timeout, the turn ending or too much
|
|
95
|
+
* output stops everything it started, and so does its own exit. */
|
|
96
|
+
export function runCommand(
|
|
97
|
+
tool: CommandTool,
|
|
98
|
+
cwd: string,
|
|
99
|
+
input: unknown,
|
|
100
|
+
signal?: AbortSignal,
|
|
101
|
+
): Promise<unknown> {
|
|
102
|
+
return new Promise((done, fail) => {
|
|
103
|
+
if (signal?.aborted)
|
|
104
|
+
return fail(new Error(`${tool.command} was stopped: the turn ended`))
|
|
105
|
+
const child = spawn(tool.command, tool.args ?? [], {
|
|
106
|
+
cwd,
|
|
107
|
+
env: {
|
|
108
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
109
|
+
HOME: process.env.HOME ?? cwd,
|
|
110
|
+
},
|
|
111
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
112
|
+
detached: true,
|
|
113
|
+
})
|
|
114
|
+
let stopped: string | undefined
|
|
115
|
+
const stopAll = (why?: string) => {
|
|
116
|
+
if (why && !stopped) stopped = why
|
|
117
|
+
try {
|
|
118
|
+
process.kill(-child.pid!, 'SIGKILL')
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
const ms = tool.timeoutMs ?? TOOL_TIMEOUT_MS
|
|
122
|
+
const timer = setTimeout(() => stopAll(`ran longer than ${ms} ms`), ms)
|
|
123
|
+
const onAbort = () => stopAll('the turn ended')
|
|
124
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
125
|
+
const finish = () => {
|
|
126
|
+
clearTimeout(timer)
|
|
127
|
+
signal?.removeEventListener('abort', onAbort)
|
|
128
|
+
}
|
|
129
|
+
const out: Buffer[] = []
|
|
130
|
+
const err: Buffer[] = []
|
|
131
|
+
let size = 0
|
|
132
|
+
const collect = (into: Buffer[]) => (chunk: Buffer) => {
|
|
133
|
+
size += chunk.length
|
|
134
|
+
if (size > COMMAND_OUTPUT_MAX)
|
|
135
|
+
stopAll(`printed more than ${COMMAND_OUTPUT_MAX} bytes`)
|
|
136
|
+
else into.push(chunk)
|
|
137
|
+
}
|
|
138
|
+
child.stdout!.on('data', collect(out))
|
|
139
|
+
child.stderr!.on('data', collect(err))
|
|
140
|
+
// A command that exits without reading its input is not an error here.
|
|
141
|
+
child.stdin!.on('error', () => {})
|
|
142
|
+
child.stdin!.end(JSON.stringify(input))
|
|
143
|
+
child.on('error', (e) => {
|
|
144
|
+
finish()
|
|
145
|
+
fail(new Error(`${tool.command} didn't run: ${e.message}`))
|
|
146
|
+
})
|
|
147
|
+
child.on('exit', () => {
|
|
148
|
+
// It answered in time: from here only the 1 s below governs the wait.
|
|
149
|
+
finish()
|
|
150
|
+
stopAll()
|
|
151
|
+
// Something that left the group can still hold the pipes open; the
|
|
152
|
+
// call ends anyway.
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
child.stdout!.destroy()
|
|
155
|
+
child.stderr!.destroy()
|
|
156
|
+
}, 1000).unref()
|
|
157
|
+
})
|
|
158
|
+
child.on('close', (code, killed) => {
|
|
159
|
+
finish()
|
|
160
|
+
const stderr = Buffer.concat(err).toString('utf8').trim().slice(-2000)
|
|
161
|
+
if (stopped)
|
|
162
|
+
return fail(new Error(`${tool.command} was stopped: ${stopped}`))
|
|
163
|
+
if (code !== 0)
|
|
164
|
+
return fail(
|
|
165
|
+
new Error(
|
|
166
|
+
`${tool.command} exited ${code ?? killed}${stderr ? `: ${stderr}` : ''}`,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
const stdout = Buffer.concat(out).toString('utf8')
|
|
170
|
+
try {
|
|
171
|
+
done(JSON.parse(stdout))
|
|
172
|
+
} catch {
|
|
173
|
+
fail(new Error(`${tool.command} didn't answer with JSON on stdout`))
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ModelEndpoint {
|
|
180
|
+
/** Base URL of the model API, e.g. the station's `http://127.0.0.1:8790/anthropic`. */
|
|
181
|
+
baseURL: string
|
|
182
|
+
/** What to send as the key: in a VM, a placeholder the station replaces. */
|
|
183
|
+
key: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The model an agent names (`provider/model`), at the given endpoint. */
|
|
187
|
+
export function modelFor(
|
|
188
|
+
agent: Pick<Manifest, 'model'>,
|
|
189
|
+
endpoint: ModelEndpoint,
|
|
190
|
+
): LanguageModel {
|
|
191
|
+
// Always strings: the provider would fall back to ANTHROPIC_* variables otherwise.
|
|
192
|
+
if (!endpoint.key || !endpoint.baseURL)
|
|
193
|
+
throw new Error('the model endpoint needs a base URL and a key')
|
|
194
|
+
const [provider, id] = agent.model.split('/')
|
|
195
|
+
if (provider !== 'anthropic')
|
|
196
|
+
throw new Error(
|
|
197
|
+
`mirab talks to anthropic models for now; ${agent.model} isn't one`,
|
|
198
|
+
)
|
|
199
|
+
return createAnthropic({ baseURL: endpoint.baseURL, apiKey: endpoint.key })(
|
|
200
|
+
id,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface TurnResult {
|
|
205
|
+
text: string
|
|
206
|
+
/** The turn hit MAX_STEPS model calls before the model answered. */
|
|
207
|
+
truncated: boolean
|
|
208
|
+
/** The conversation after this turn, for the next one. */
|
|
209
|
+
messages: ModelMessage[]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One turn: the person's message in, the agent's answer out, with any tool calls between. */
|
|
213
|
+
export async function runTurn(options: {
|
|
214
|
+
loaded: LoadedAgent
|
|
215
|
+
model: LanguageModel
|
|
216
|
+
history: ModelMessage[]
|
|
217
|
+
message: string
|
|
218
|
+
signal?: AbortSignal
|
|
219
|
+
}): Promise<TurnResult> {
|
|
220
|
+
const messages: ModelMessage[] = [
|
|
221
|
+
...options.history,
|
|
222
|
+
{ role: 'user', content: options.message },
|
|
223
|
+
]
|
|
224
|
+
const result = await generateText({
|
|
225
|
+
model: options.model,
|
|
226
|
+
instructions: options.loaded.agent.instructions,
|
|
227
|
+
messages,
|
|
228
|
+
tools: options.loaded.tools,
|
|
229
|
+
stopWhen: stepCountIs(MAX_STEPS),
|
|
230
|
+
timeout: { totalMs: TURN_TIMEOUT_MS, toolMs: TOOL_TIMEOUT_MS },
|
|
231
|
+
abortSignal: options.signal,
|
|
232
|
+
})
|
|
233
|
+
return {
|
|
234
|
+
text: result.text,
|
|
235
|
+
truncated: result.finishReason === 'tool-calls',
|
|
236
|
+
// Every step's messages (tool calls and results too), not just the last.
|
|
237
|
+
messages: [...messages, ...result.responseMessages],
|
|
238
|
+
}
|
|
239
|
+
}
|