mirab 0.0.0-stage → 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.
Files changed (105) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +226 -2
  3. package/package.json +58 -4
  4. package/src/agent/approved.ts +261 -0
  5. package/src/agent/define.ts +235 -0
  6. package/src/agent/guest.ts +138 -0
  7. package/src/agent/init.ts +150 -0
  8. package/src/agent/manifest.ts +141 -0
  9. package/src/agent/modules.ts +23 -0
  10. package/src/agent/runtime.ts +239 -0
  11. package/src/agent-profiles.ts +327 -0
  12. package/src/api/bootstrap-contract.ts +95 -0
  13. package/src/api/channel-contract.ts +19 -0
  14. package/src/api/client.ts +100 -0
  15. package/src/api/contract.ts +2454 -0
  16. package/src/api/events-contract.ts +103 -0
  17. package/src/api/import-contract.ts +22 -0
  18. package/src/api/orchestrator-contract.ts +73 -0
  19. package/src/api/runner-contract.ts +25 -0
  20. package/src/api/scopes.ts +41 -0
  21. package/src/api/sessions-contract.ts +71 -0
  22. package/src/api/settings-contract.ts +50 -0
  23. package/src/channel/bridge.ts +97 -0
  24. package/src/channel/local.ts +154 -0
  25. package/src/channel/state.ts +11 -0
  26. package/src/channel/wire.ts +104 -0
  27. package/src/dev-sync.ts +206 -0
  28. package/src/dev.ts +453 -0
  29. package/src/events-tail.ts +75 -0
  30. package/src/images.ts +498 -0
  31. package/src/index.ts +21 -0
  32. package/src/invocation.ts +34 -0
  33. package/src/isolation/anchored-fs.ts +284 -0
  34. package/src/isolation/colima.ts +159 -0
  35. package/src/isolation/container.ts +189 -0
  36. package/src/isolation/firecracker.ts +414 -0
  37. package/src/isolation/guest-protocol.json +1 -0
  38. package/src/isolation/host-user.ts +247 -0
  39. package/src/isolation/image.ts +336 -0
  40. package/src/isolation/index.ts +477 -0
  41. package/src/isolation/multipass.ts +541 -0
  42. package/src/isolation/network.ts +167 -0
  43. package/src/isolation/policy.ts +86 -0
  44. package/src/isolation/session.ts +385 -0
  45. package/src/isolation/sync.ts +329 -0
  46. package/src/isolation/vz-network-setup.ts +247 -0
  47. package/src/isolation/vz.ts +312 -0
  48. package/src/mirab-main.ts +536 -0
  49. package/src/mirab.ts +13 -0
  50. package/src/runner/define.ts +187 -0
  51. package/src/runner/image.ts +239 -0
  52. package/src/runner/index.ts +37 -0
  53. package/src/runner/serve.ts +491 -0
  54. package/src/runner/toolsets.ts +205 -0
  55. package/src/spawn-adapters/claude.ts +112 -0
  56. package/src/spawn-adapters/codex-logs.ts +130 -0
  57. package/src/spawn-adapters/codex-notify.ts +73 -0
  58. package/src/spawn-adapters/codex.ts +89 -0
  59. package/src/spawn-adapters/index.ts +40 -0
  60. package/src/spawn-adapters/types.ts +55 -0
  61. package/src/spawn-attach.ts +243 -0
  62. package/src/spawn-binary.ts +90 -0
  63. package/src/spawn-bridge.ts +504 -0
  64. package/src/spawn-bundle.ts +16 -0
  65. package/src/spawn-detach.ts +301 -0
  66. package/src/spawn-events.ts +35 -0
  67. package/src/spawn-hook.ts +86 -0
  68. package/src/spawn-input.ts +107 -0
  69. package/src/spawn-native.ts +49 -0
  70. package/src/spawn-nudge-worker.ts +61 -0
  71. package/src/spawn-nudge.ts +446 -0
  72. package/src/spawn-output.ts +45 -0
  73. package/src/spawn-queue.ts +135 -0
  74. package/src/spawn-quiet.ts +69 -0
  75. package/src/spawn-report.ts +114 -0
  76. package/src/spawn-sessions.ts +166 -0
  77. package/src/spawn-vm.ts +356 -0
  78. package/src/spawn-worker.ts +766 -0
  79. package/src/spawn.ts +468 -0
  80. package/src/station/config.ts +89 -0
  81. package/src/station/gateway.ts +94 -0
  82. package/src/station/vault.ts +96 -0
  83. package/src/test-cli.ts +90 -0
  84. package/src/tools.ts +3 -0
  85. package/types/agent/define.d.ts +76 -0
  86. package/types/agent/manifest.d.ts +53 -0
  87. package/types/agent/modules.d.ts +1 -0
  88. package/types/images.d.ts +124 -0
  89. package/types/index.d.ts +6 -0
  90. package/types/isolation/colima.d.ts +37 -0
  91. package/types/isolation/container.d.ts +25 -0
  92. package/types/isolation/firecracker.d.ts +55 -0
  93. package/types/isolation/host-user.d.ts +47 -0
  94. package/types/isolation/image.d.ts +158 -0
  95. package/types/isolation/index.d.ts +128 -0
  96. package/types/isolation/network.d.ts +33 -0
  97. package/types/isolation/policy.d.ts +29 -0
  98. package/types/isolation/session.d.ts +99 -0
  99. package/types/isolation/vz.d.ts +63 -0
  100. package/types/runner/define.d.ts +101 -0
  101. package/types/runner/image.d.ts +52 -0
  102. package/types/runner/index.d.ts +13 -0
  103. package/types/runner/serve.d.ts +69 -0
  104. package/types/runner/toolsets.d.ts +35 -0
  105. package/types/tools.d.ts +2 -0
@@ -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
+ }