forked from milind-soni/OpenMausBot
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontracts.ts
More file actions
385 lines (357 loc) · 16.5 KB
/
Copy pathcontracts.ts
File metadata and controls
385 lines (357 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
// Canonical harness contracts — ported from upstream
// (apps/server/src/provider/ProviderDriver.ts, Services/ProviderAdapter.ts,
// packages/contracts/src/{provider,providerInstance,providerRuntime}.ts),
// de-Effect-ed: Promises instead of Effect, listener callbacks instead of
// Stream. The shapes and names are kept so the two codebases stay mutually
// readable.
export type DriverKind = string;
export type InstanceId = string;
export type ThreadId = string;
export type TurnId = string;
export type CloudBackend = "box" | "vps";
export type ProviderErrorCode =
| "missing_cli"
| "invalid_credentials"
| "inactive_subscription"
| "quota_or_region_restriction"
| "upstream_outage"
| "model_catalog_outage";
export class ProviderError extends Error {
readonly code: ProviderErrorCode;
constructor(code: ProviderErrorCode, message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "ProviderError";
this.code = code;
}
}
/** Reasoning-effort levels, ascending. A union of everything any engine
* accepts; each driver declares the subset its CLI will take. */
export const EFFORT_LEVELS = ["none", "low", "medium", "high", "xhigh", "max"] as const;
export type EffortLevel = (typeof EFFORT_LEVELS)[number];
/** Narrow untrusted API/config input before it becomes a model selection. */
export function isEffortLevel(value: unknown): value is EffortLevel {
return typeof value === "string" && (EFFORT_LEVELS as readonly string[]).includes(value);
}
// ── model selection ────────────────────────────────────────────────────
// "Which model" is a data value carried on the request, never a service
// binding (upstream ModelSelectionWire). instanceId is the routing key.
export interface ModelSelection {
instanceId: InstanceId;
model: string;
/** Optional: no effort means no flag, and the CLI keeps its own default. */
effort?: EffortLevel;
}
// ── instance configuration envelope ────────────────────────────────────
// `driver` is any slug — NOT validated against known drivers; unknown
// drivers round-trip and surface as unavailable shadow snapshots so a
// config from a newer build downgrades safely.
export interface InstanceConfig {
driver: DriverKind;
displayName?: string;
accentColor?: string;
environment?: Record<string, string>;
enabled?: boolean;
config?: unknown;
}
export type InstanceConfigMap = Record<InstanceId, InstanceConfig>;
// ── canonical runtime events ───────────────────────────────────────────
// Subset of upstream's 49-member ProviderRuntimeEvent union — the ~12 types
// the recipe says to start with, sharing one base. `raw` carries the
// native protocol message when a consumer needs to see behind the
// normalization.
export interface RuntimeEventBase {
eventId: string;
provider: DriverKind;
providerInstanceId?: InstanceId;
threadId: ThreadId;
createdAt: string;
turnId?: TurnId;
itemId?: string;
requestId?: string;
raw?: { source: string; payload: unknown };
}
export type RuntimeEvent = RuntimeEventBase &
(
| { type: "session.started"; sessionId: string | null; model?: string | null }
| { type: "session.exited"; reason?: string }
| { type: "turn.started" }
| {
type: "turn.completed";
ok: boolean;
stopReason?: string | null;
cost?: number | null;
denials?: string[];
/** THIS turn's token total, as the provider reports it at the end.
* The one figure the harness accumulates — thread.token-usage.updated
* is a live indicator whose meaning differs per driver (a per-call
* delta, a thread total, a per-step figure) and must never be summed. */
usage?: { input: number; output: number };
}
| { type: "item.started"; itemType: "tool" | "reasoning"; title?: string }
| { type: "item.updated"; itemType: "tool" | "reasoning"; tokens?: number | null }
| { type: "item.completed"; itemType: "tool"; ok: boolean }
| { type: "item.completed"; itemType: "assistant_text"; text: string }
| { type: "content.delta"; streamKind: "assistant_text" | "reasoning_text"; delta: string }
| {
type: "request.opened";
requestType: "permission" | "question";
tool: string;
summary: string;
choices?: string[];
approvalScope?: "local-computer";
}
| {
type: "request.resolved";
behavior: "allow" | "deny" | "answer";
/** who decided: a person, auto mode, the ask's own timeout, the
* harness (turn ended / settings changed), or nobody — the answerer
* was already gone and the action never ran */
source: "user" | "auto" | "timeout" | "system" | "unavailable" | "peer";
approvalScope?: "local-computer";
}
| { type: "thread.token-usage.updated"; input: number; output: number }
// `setup: true` marks a failure the user fixes by installing or
// configuring something, not by retrying — the UI offers setup instead.
| { type: "runtime.error"; message: string; setup?: boolean }
);
export type RuntimeEventListener = (event: RuntimeEvent) => void;
/** What became of an answer to an ask. `allowed-once` grants only the
* asked-about action — broadening ("always allow") stays a separate,
* explicit step. `unavailable` is the fail-closed default: no answerer,
* no action. */
export type RequestOutcome = "allowed-once" | "rejected" | "answered" | "unavailable";
// ── adapter contract (upstream ProviderAdapterShape, promise-flavored) ──
// The conversation runtime every provider is flattened into. streamEvents
// becomes onEvent(listener) → unsubscribe; sessions start implicitly on
// the first turn (the agentcal per-turn-process model) with resumeCursor
// carrying the provider-native continuation (e.g. a claude session id).
export interface SendTurnInput {
threadId: ThreadId;
text: string;
model?: string;
effort?: EffortLevel;
resumeCursor?: unknown;
/** Prior turns for transcript-replay providers (API-backed drivers). */
transcript?: Array<{ role: "user" | "assistant"; text: string }>;
/** Bot persona (name/title/description) as a system prompt. */
system?: string;
/** Per-bot integrations the driver may hand to the agent as tools. */
integrations?: {
/** A local stdio bridge owns the remote Composio transport. Keeping the
* bridge harness-controlled lets it turn connection requests into trusted
* chat cards consistently across provider CLIs. */
composio?: { command: string; args: string[]; env: Record<string, string> };
/** Cloud computer, reached through OpenMausBot's REST-to-MCP adapter.
* `control` is the harness's loopback who-is-driving endpoint: the
* adapter consults it so a person who takes the wheel in the panel
* pauses the bot's hands mid-turn instead of typing over them. */
computer?: {
kind?: "box";
boxId: string;
token: string;
control?: { url: string; token: string };
};
/** Direct stdio connection to a Cua Driver MCP server (host, sandbox, or
* VPS). `scope` is set only for the user's host desktop; isolated and
* remote computers intentionally omit it so host-only approval rules
* cannot change their semantics. */
localComputer?: {
command: string;
args: string[];
env: Record<string, string>;
platform?: "darwin" | "linux" | "win32";
generation?: string;
scope?: "local-computer";
};
/** Peer-agent comms: an MCP proxy (list_bots / ask_bot) that routes back
* through the harness so this bot can message other bots. The harness
* owns turns, permissions, and recursion limits; the proxy only forwards. */
agents?: { command: string; args: string[]; env: Record<string, string> };
/** Physical Android phone tools over authorized USB debugging. */
phone?: { command: string; args: string[]; env: Record<string, string> };
/** dweb network daemon: an MCP proxy exposing dweb status, repo, and
* opencode model access as tools. url is the dweb HTTP base. */
dweb?: { url: string };
};
cwd?: string;
}
export interface TurnStartResult {
turnId: TurnId;
}
export interface ProviderAdapter {
readonly provider: DriverKind;
readonly capabilities: {
sessionModelSwitch: "in-session" | "unsupported";
/** True when the driver mounts turn.integrations.agents as MCP tools —
* the harness only offers agents tooling (and prompts about it) to
* drivers that can actually hand it to the agent. */
agentsMcp?: boolean;
/** True when the driver mounts turn.integrations.computer (the box's
* screenshot/click tools). Same rule as agentsMcp: a bot must never be
* told it has a computer whose tools its driver cannot mount — it
* burns turns hunting for tools that aren't there. */
computerMcp?: boolean;
/** True when the driver mounts turn.integrations.composio (the user's
* connected apps). Same rule again: a key in the config says the user
* HAS those connections, not that this driver can reach them. */
composioMcp?: boolean;
/** True when the driver can mount the first-party physical-phone MCP. */
phoneMcp?: boolean;
/** True when this engine accepts images in the prompt — gates image
* paste in the composer. Same rule as computerMcp: never offer an
* attachment an engine cannot open (a bot told it has an image it
* cannot read burns the turn). */
images?: boolean;
/** Effort levels this driver can pass to its CLI, ascending. Absent =
* the driver cannot set effort, so the app never offers the control —
* same rule as computerMcp: never show a knob the driver cannot turn. */
effortLevels?: readonly EffortLevel[];
/** True when the driver keeps a live session across turns and can take
* a user message MID-TURN (delivered before the model's next call —
* "steer"). The composer stays open during a turn on such an engine;
* others keep the queue-one-and-wait behaviour. Same rule as the other
* flags: never show a control the driver cannot honour. */
queueing?: boolean;
/** True only when local MCP calls can reach the human approval channel.
* Full-auto/bypass provider instances must leave this false. */
localComputerMcp?: boolean;
};
sendTurn(input: SendTurnInput): Promise<TurnStartResult>;
interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise<void>;
/** Answer a pending ask. Resolves with what actually happened — never
* throws for an ask that is no longer there: `unavailable` means nobody
* could take the answer (the turn ended, the broker died, the driver
* has no asks), and the caller treats it as a deny. Callers branch on the
* outcome, not on prose. */
respondToRequest(
threadId: ThreadId,
requestId: string,
decision: { behavior: "allow" | "deny" | "answer"; message?: string },
): Promise<RequestOutcome>;
/** Deliver a user message into the RUNNING turn on this thread. Resolves
* false when there is no live turn to steer (the caller then sends it as
* a normal turn). Only drivers with `capabilities.queueing` implement it. */
steer?(threadId: ThreadId, text: string): Promise<boolean>;
hasSession(threadId: ThreadId): boolean;
stopAll(): Promise<void>;
onEvent(listener: RuntimeEventListener): () => void;
}
// ── provider snapshot (upstream ServerProviderShape, reduced) ────────────
export interface ProviderSnapshot {
state: "available" | "unavailable";
reason?: string;
authenticated?: boolean;
version?: string | null;
/** How this instance is paid for, when the driver can tell: a reported
* cost on a subscription is notional and the UI labels it as such. */
billing?: "metered" | "subscription";
}
// ── engine install descriptor ───────────────────────────────────────────
// How a user gets this engine onto their machine. Declared by the driver so
// that adding a provider stays "one file in drivers/ plus a registration":
// onboarding, the model picker, and settings all render from this instead of
// hardcoding per-engine copy in the UI.
//
// Installing is rarely the whole job — most CLIs then need an interactive
// sign-in, which is why signInCommand exists and why the UI sends people to a
// terminal rather than trying to shell out silently.
export interface EngineInstall {
/** One-liner per platform. Omit a platform that has no such command —
* the UI falls back to docsUrl rather than offering something that
* cannot work there (a curl|bash line is not a Windows command). */
command?: Partial<Record<"darwin" | "win32" | "linux", string>>;
/** Docs or download page. The only route for GUI-installed engines. */
docsUrl?: string;
/** Interactive sign-in run after installing, when install isn't enough. */
signInCommand?: string;
/** `command` needs npm on PATH, so the UI can say so when Node is absent. */
needsNode?: boolean;
}
// ── driver SPI (upstream ProviderDriver — a plain record, not a service) ─
// `create` owns ALL per-instance state; two create calls share nothing.
// Failures must reject, never throw synchronously — the registry downgrades
// a rejection to an unavailable shadow snapshot.
export type ModelCostClass = "free" | "paid" | "paid_subscription" | "paid_metered" | "local" | "unknown";
export interface ModelRuntimeStatus {
configured: boolean;
reachable: boolean;
verified: boolean;
admitted: boolean;
busy: boolean;
}
export interface ModelOption {
/** The model id understood by this concrete OpenMausBot driver. */
id: string;
label: string;
custom?: boolean;
loaded?: boolean;
/** Fleet-wide stable id. Present only for rows projected by the guarded
* secret-free AOS model catalog. */
canonicalId?: string;
provider?: string;
host?: string;
costClass?: ModelCostClass;
manualOnly?: boolean;
isDefault?: boolean;
capabilities?: string[];
status?: ModelRuntimeStatus;
/** False means the row stays visible for inventory/truth, but cannot be
* selected until a fresh catalog refresh marks it admitted and idle. */
selectable?: boolean;
reason?: string;
lastVerified?: string;
verificationReceipt?: string;
/** total context window in tokens, when the driver knows it — sizes
* the model-facing rebuild (server/context-rebuild.ts). Unknown falls
* back to a pattern table over the model id, then a conservative default. */
contextWindow?: number;
}
export interface ModelCatalog {
default: string;
options: ModelOption[];
}
export interface DriverCreateInput<Config> {
instanceId: InstanceId;
displayName: string | undefined;
environment: Record<string, string>;
enabled: boolean;
config: Config;
}
export interface ProviderInstance {
readonly instanceId: InstanceId;
readonly driverKind: DriverKind;
readonly displayName: string | undefined;
readonly enabled: boolean;
readonly models: ModelCatalog;
/** Refresh a live catalog without recreating the provider instance. */
readonly refreshModels?: () => Promise<void>;
readonly adapter: ProviderAdapter;
snapshot(): Promise<ProviderSnapshot>;
/** Cheap one-shot text call (upstream TextGeneration) — titles, summaries. */
generateText?(prompt: string): Promise<string>;
dispose(): Promise<void>;
}
/** How an engine is presented in the picker rail.
* `subscription` — first-party cloud catalog; Custom is extra.
* `custom` — no subscription catalog; Custom is the product. */
export type EngineAccess = "subscription" | "custom";
export interface ProviderDriver<Config = unknown> {
readonly driverKind: DriverKind;
readonly metadata: {
displayName: string;
supportsMultipleInstances?: boolean;
access?: EngineAccess;
};
/** How to get this engine installed. Omit for engines that need no local
* binary (API-key drivers), which is what makes it optional. */
readonly install?: EngineInstall;
/** Decode the opaque config envelope; throw on invalid (→ shadow). */
decodeConfig(raw: unknown): Config;
defaultConfig(): Config;
readonly models: ModelCatalog;
create(input: DriverCreateInput<Config>): Promise<ProviderInstance>;
}
export type AnyProviderDriver = ProviderDriver<any>;
let eventCounter = 0;
export const newEventId = () => `ev-${Date.now().toString(36)}-${(eventCounter++).toString(36)}`;
export const newId = () => crypto.randomUUID();