@fungi.computer/oyster 0.3.2 → 0.4.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/dist/agent-computer-source.d.ts +42 -0
- package/dist/agent-computer-source.js +88 -0
- package/dist/agent-computers.d.ts +104 -0
- package/dist/agent-computers.js +227 -0
- package/dist/agents.d.ts +10 -0
- package/dist/agents.js +33 -0
- package/dist/browser/boot.d.ts +2 -2
- package/dist/browser/boot.js +10 -3
- package/dist/browser/browser.d.ts +1 -1
- package/dist/browser/browser.js +2 -1
- package/dist/browser/cdp-client.d.ts +2 -0
- package/dist/browser/errors.d.ts +2 -2
- package/dist/browser/errors.js +2 -2
- package/dist/browser/index.d.ts +3 -2
- package/dist/browser/index.js +3 -2
- package/dist/browser/page.js +11 -4
- package/dist/browser/sessions.d.ts +7 -4
- package/dist/browser/sessions.js +159 -0
- package/dist/browser/url-policy.d.ts +4 -2
- package/dist/browser/url-policy.js +4 -2
- package/dist/computer-control-port.d.ts +26 -0
- package/dist/computer-control-port.js +1 -0
- package/dist/host.d.ts +3 -0
- package/dist/machine-link/delivery.d.ts +1 -1
- package/dist/machine-link/delivery.js +2 -3
- package/dist/machine-link/host.d.ts +37 -6
- package/dist/machine-link/host.js +209 -14
- package/dist/machine-link/link.d.ts +13 -1
- package/dist/machine-link/link.js +20 -7
- package/dist/mcp.d.ts +144 -10
- package/dist/mcp.js +130 -13
- package/dist/page/computer-handles.d.ts +3 -3
- package/dist/page-script.js +1 -1
- package/dist/standalone/agent-computers.d.ts +29 -0
- package/dist/standalone/agent-computers.js +101 -0
- package/dist/standalone/agent-mail.d.ts +41 -18
- package/dist/standalone/agent-model-choice.d.ts +26 -0
- package/dist/standalone/agent-model-choice.js +72 -0
- package/dist/standalone/agents.d.ts +44 -4
- package/dist/standalone/agents.js +502 -46
- package/dist/standalone/computer-control.d.ts +4 -0
- package/dist/standalone/computer-control.js +62 -0
- package/dist/standalone/computer-fleet.d.ts +1 -0
- package/dist/standalone/computer-fleet.js +1 -1
- package/dist/standalone/computer.d.ts +21 -2
- package/dist/standalone/computer.js +12 -0
- package/dist/standalone/machine-link.d.ts +10 -0
- package/dist/standalone/machine-link.js +6 -0
- package/dist/standalone/registry.d.ts +9 -0
- package/dist/standalone/registry.js +9 -0
- package/dist/standalone/worker.js +12 -0
- package/dist/standalone/workflow-binding.d.ts +2 -2
- package/dist/standalone/workflow-host.d.ts +11 -11
- package/dist/standalone/workflows.d.ts +22 -22
- package/dist/storage-frame.d.ts +3 -3
- package/dist/workers-git.d.ts +2 -0
- package/dist/workers-git.js +2 -0
- package/dist/workers.d.ts +8 -8
- package/docs/api/agents.md +66 -0
- package/docs/api/apps.md +104 -104
- package/docs/api/build-contract.md +35 -35
- package/docs/api/build.md +12 -12
- package/docs/api/host.md +61 -61
- package/docs/api/mcp.md +127 -13
- package/docs/api/root-data.md +1 -1
- package/docs/api/tenancy.md +1 -1
- package/docs/api/workers-git.md +3 -2
- package/docs/api/workers.md +81 -83
- package/package.json +25 -17
|
@@ -18,11 +18,19 @@ import { memberInboxItemSchema } from "@fungi.computer/puffball/inbox";
|
|
|
18
18
|
* Worker's AI binding, with no API token.
|
|
19
19
|
*/
|
|
20
20
|
import { agentDirectoryForScope } from "./agent-directory.js";
|
|
21
|
+
import { createAgentComputers, } from "../agent-computers.js";
|
|
22
|
+
import { createAgentComputerSource, readAgentComputerConnections, } from "../agent-computer-source.js";
|
|
23
|
+
import { standaloneAgentComputerPorts, } from "./agent-computers.js";
|
|
24
|
+
import { FleetGrantRefused, } from "@fungi.computer/computer/fleet";
|
|
25
|
+
import { homeExtension, homeGrant, } from "@fungi.computer/shiitake-extensions/home/host";
|
|
26
|
+
import { homeContract } from "@fungi.computer/shiitake-extensions/home";
|
|
21
27
|
import { DurableObject } from "cloudflare:workers";
|
|
22
28
|
import { ShiitakeDO } from "@fungi.computer/shiitake/cloudflare";
|
|
23
29
|
import { agentStoreKeeping } from "./agent-store.js";
|
|
24
30
|
import { standaloneBrowserSource } from "./browser.js";
|
|
25
31
|
import { configuredProviderEgress, providerFetch, } from "@fungi.computer/veil/gatekeeper";
|
|
32
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
|
33
|
+
import { agentModelChoice, agentModelChoiceSchema, AgentModelRefused, selectAgentModel, } from "./agent-model-choice.js";
|
|
26
34
|
import { CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL, createAiBindingFetch, } from "@earendil-works/pi-ai/api/cloudflare-ai-binding";
|
|
27
35
|
import { cloudflareWorkersAIProvider } from "@earendil-works/pi-ai/providers/cloudflare-workers-ai";
|
|
28
36
|
import * as Schema from "effect/Schema";
|
|
@@ -37,37 +45,105 @@ import { oysterPrincipalIsOwner, oysterPrincipalId, } from "../host.js";
|
|
|
37
45
|
import { defaultTenant, tenantIdSchema, tenantScope, } from "../tenant.js";
|
|
38
46
|
import { createCodemodeSandbox } from "@fungi.computer/mycelium/workers";
|
|
39
47
|
import { maxAgentMcpServers, } from "../agents.js";
|
|
40
|
-
/** Default Workers AI chat model: one that reliably calls tools. */
|
|
41
|
-
const defaultAgentModel = "@cf/moonshotai/kimi-k2.6";
|
|
42
48
|
/** Own transport evidence and exact-message decisions for one model lease. */
|
|
43
49
|
function observedModels(model, defaults) {
|
|
44
50
|
return {
|
|
45
51
|
acquire: ({ signal }) => {
|
|
46
|
-
const streamSimple = streamModel;
|
|
47
52
|
const retryableMessages = new WeakSet();
|
|
53
|
+
const safeFailures = new WeakMap();
|
|
54
|
+
const failure = (message, code) => {
|
|
55
|
+
// Pi can include raw response bodies in errorMessage. No provider
|
|
56
|
+
// prose enters a Session, retry journal, model context or diagnostics.
|
|
57
|
+
const safe = `Model request failed (${code}). Retry the request.`;
|
|
58
|
+
const terminal = {
|
|
59
|
+
role: "assistant",
|
|
60
|
+
content: [],
|
|
61
|
+
api: model.api,
|
|
62
|
+
provider: model.provider,
|
|
63
|
+
model: model.id,
|
|
64
|
+
usage: message.usage,
|
|
65
|
+
stopReason: message.stopReason,
|
|
66
|
+
errorMessage: safe,
|
|
67
|
+
timestamp: message.timestamp,
|
|
68
|
+
};
|
|
69
|
+
safeFailures.set(terminal, safe);
|
|
70
|
+
if (code === "provider_unavailable")
|
|
71
|
+
retryableMessages.add(terminal);
|
|
72
|
+
return terminal;
|
|
73
|
+
};
|
|
48
74
|
return Promise.resolve({
|
|
49
75
|
model,
|
|
50
76
|
retryable: (message) => !signal.aborted &&
|
|
51
77
|
message.stopReason === "error" &&
|
|
52
78
|
retryableMessages.has(message),
|
|
53
|
-
|
|
79
|
+
failureReason: (message) => safeFailures.get(message),
|
|
80
|
+
stream: (selected, context, options) => {
|
|
54
81
|
const upstream = { ...options, ...defaults };
|
|
55
82
|
const observed = observeProviderStatus(upstream);
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
const events = createAssistantMessageEventStream();
|
|
84
|
+
void (async () => {
|
|
85
|
+
try {
|
|
86
|
+
const response = await streamModel(selected, context, {
|
|
87
|
+
...upstream,
|
|
88
|
+
fetch: observed.fetch,
|
|
89
|
+
onProviderStreamEvent: observed.onProviderStreamEvent,
|
|
90
|
+
});
|
|
91
|
+
for await (const event of response) {
|
|
92
|
+
if (event.type === "error") {
|
|
93
|
+
const terminal = failure(event.error, observed.rejection()?.code ?? "provider_rejected");
|
|
94
|
+
events.push({ ...event, error: terminal });
|
|
95
|
+
events.end(terminal);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (event.type === "done" &&
|
|
99
|
+
(event.message.stopReason === "error" ||
|
|
100
|
+
event.message.stopReason === "aborted")) {
|
|
101
|
+
const terminal = failure(event.message, observed.rejection()?.code ?? "provider_rejected");
|
|
102
|
+
events.push({ ...event, message: terminal });
|
|
103
|
+
events.end(terminal);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
events.push(event);
|
|
107
|
+
}
|
|
108
|
+
const result = await response.result();
|
|
109
|
+
events.end(result.stopReason === "error" || result.stopReason === "aborted"
|
|
110
|
+
? failure(result, observed.rejection()?.code ?? "provider_rejected")
|
|
111
|
+
: result);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// A transport exception can contain headers or provider text.
|
|
115
|
+
const terminal = failure({
|
|
116
|
+
role: "assistant",
|
|
117
|
+
content: [],
|
|
118
|
+
api: model.api,
|
|
119
|
+
provider: model.provider,
|
|
120
|
+
model: model.id,
|
|
121
|
+
usage: {
|
|
122
|
+
input: 0,
|
|
123
|
+
output: 0,
|
|
124
|
+
cacheRead: 0,
|
|
125
|
+
cacheWrite: 0,
|
|
126
|
+
totalTokens: 0,
|
|
127
|
+
cost: {
|
|
128
|
+
input: 0,
|
|
129
|
+
output: 0,
|
|
130
|
+
cacheRead: 0,
|
|
131
|
+
cacheWrite: 0,
|
|
132
|
+
total: 0,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
stopReason: signal.aborted ? "aborted" : "error",
|
|
136
|
+
timestamp: Date.now(),
|
|
137
|
+
}, "provider_unavailable");
|
|
138
|
+
events.push({
|
|
139
|
+
type: "error",
|
|
140
|
+
reason: signal.aborted ? "aborted" : "error",
|
|
141
|
+
error: terminal,
|
|
142
|
+
});
|
|
143
|
+
events.end(terminal);
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
return events;
|
|
71
147
|
},
|
|
72
148
|
release: () => Promise.resolve(),
|
|
73
149
|
});
|
|
@@ -78,8 +154,8 @@ function observedModels(model, defaults) {
|
|
|
78
154
|
* Workers AI over the AI binding. Requests go through AI Gateway's
|
|
79
155
|
* `workers-ai` passthrough on the binding, so no API token is needed.
|
|
80
156
|
*/
|
|
81
|
-
function workersAiModels(ai, options
|
|
82
|
-
const id = options.model
|
|
157
|
+
function workersAiModels(ai, options) {
|
|
158
|
+
const id = options.model;
|
|
83
159
|
const listed = cloudflareWorkersAIProvider()
|
|
84
160
|
.getModels()
|
|
85
161
|
.find((model) => model.id === id);
|
|
@@ -90,7 +166,7 @@ function workersAiModels(ai, options = {}) {
|
|
|
90
166
|
baseUrl: `https://workers-binding.ai/ai-gateway/gateways/${options.gateway ?? "default"}/workers-ai/v1`,
|
|
91
167
|
};
|
|
92
168
|
const fetch = createAiBindingFetch(ai);
|
|
93
|
-
|
|
169
|
+
const streamOptions = {
|
|
94
170
|
fetch,
|
|
95
171
|
apiKey: CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL,
|
|
96
172
|
headers: {
|
|
@@ -99,7 +175,10 @@ function workersAiModels(ai, options = {}) {
|
|
|
99
175
|
// taken as a provider key that overrides the gateway's own.
|
|
100
176
|
Authorization: null,
|
|
101
177
|
},
|
|
102
|
-
}
|
|
178
|
+
};
|
|
179
|
+
if (options.reasoning !== undefined)
|
|
180
|
+
streamOptions.reasoning = options.reasoning;
|
|
181
|
+
return observedModels(normalizeModel(model), streamOptions);
|
|
103
182
|
}
|
|
104
183
|
const policyDirectorySchema = Schema.Struct({
|
|
105
184
|
directoryName: Schema.NonEmptyString,
|
|
@@ -139,6 +218,7 @@ const toMcpServer = (stored) => ({
|
|
|
139
218
|
hasBearer: stored.bearer !== null,
|
|
140
219
|
});
|
|
141
220
|
const instructions = [
|
|
221
|
+
"Attached Computers are independent env.computers.<name> modules, including their Files and execute operations; no App is needed. Discover their exact operations before calling.",
|
|
142
222
|
"You are an agent on a personal Oyster: a small, self-hosted home for Apps.",
|
|
143
223
|
"Your tool is execute: it runs JavaScript in an isolated sandbox. Each installed App is an env.<APP> module; dotted method names are nested properties, e.g. `return await env.BONSAI.repos.list({})`.",
|
|
144
224
|
"Remote MCP servers the owner connected are env.mcp.<server> modules; call a tool as `return await env.mcp.<server>.<tool>({...})`.",
|
|
@@ -178,6 +258,91 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
178
258
|
},
|
|
179
259
|
});
|
|
180
260
|
}
|
|
261
|
+
computerSnapshot = null;
|
|
262
|
+
computerSnapshotLoad = null;
|
|
263
|
+
computerSnapshotGeneration;
|
|
264
|
+
computerSnapshotRevision = 0;
|
|
265
|
+
computerInvalidations = new Set();
|
|
266
|
+
/** Host invalidation drops captured discovery, not just its displayed names. */
|
|
267
|
+
invalidateComputerConnections() {
|
|
268
|
+
this.computerSnapshot = null;
|
|
269
|
+
this.computerSnapshotLoad = null;
|
|
270
|
+
this.computerSnapshotGeneration = undefined;
|
|
271
|
+
this.computerSnapshotRevision += 1;
|
|
272
|
+
for (const invalidate of this.computerInvalidations)
|
|
273
|
+
invalidate();
|
|
274
|
+
}
|
|
275
|
+
computerConnections() {
|
|
276
|
+
return {
|
|
277
|
+
read: () => this.computerSnapshot,
|
|
278
|
+
connect: (connection) => standaloneAgentComputerPorts({
|
|
279
|
+
computer: (computerId) => this.env.COMPUTER.getByName(computerId),
|
|
280
|
+
admit: async (captured) => {
|
|
281
|
+
const pin = await this.policyDirectory();
|
|
282
|
+
if (pin?.directoryName !== captured.scopeId)
|
|
283
|
+
throw new FleetGrantRefused();
|
|
284
|
+
return this.env.AGENT_DIRECTORY.getByName(pin.directoryName).admitAgentComputer({
|
|
285
|
+
scopeId: captured.scopeId,
|
|
286
|
+
agentId: captured.agentId,
|
|
287
|
+
attachment: captured.attachment,
|
|
288
|
+
}, pin.tenantId);
|
|
289
|
+
},
|
|
290
|
+
}, connection),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/** Revalidate the owner's generation before reusing a Home or tool snapshot. */
|
|
294
|
+
async preparedComputerSnapshot() {
|
|
295
|
+
for (;;) {
|
|
296
|
+
const revision = this.computerSnapshotRevision;
|
|
297
|
+
const generation = await this.readComputerGeneration();
|
|
298
|
+
if (revision !== this.computerSnapshotRevision)
|
|
299
|
+
continue;
|
|
300
|
+
const cachedGeneration = this.computerSnapshotGeneration;
|
|
301
|
+
if (generation !== null &&
|
|
302
|
+
cachedGeneration !== null &&
|
|
303
|
+
cachedGeneration !== undefined &&
|
|
304
|
+
generation < cachedGeneration)
|
|
305
|
+
continue;
|
|
306
|
+
if (generation !== this.computerSnapshotGeneration) {
|
|
307
|
+
this.computerSnapshot = null;
|
|
308
|
+
this.computerSnapshotLoad = null;
|
|
309
|
+
this.computerSnapshotGeneration = generation;
|
|
310
|
+
}
|
|
311
|
+
const load = (this.computerSnapshotLoad ??= this.readComputerSnapshot());
|
|
312
|
+
try {
|
|
313
|
+
const snapshot = await load;
|
|
314
|
+
if (revision !== this.computerSnapshotRevision ||
|
|
315
|
+
generation !== this.computerSnapshotGeneration)
|
|
316
|
+
continue;
|
|
317
|
+
if (snapshot !== null && snapshot.projection.revision !== generation) {
|
|
318
|
+
this.computerSnapshotGeneration = undefined;
|
|
319
|
+
this.computerSnapshotLoad = null;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
this.computerSnapshot = snapshot;
|
|
323
|
+
return snapshot;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (this.computerSnapshotLoad === load)
|
|
327
|
+
this.computerSnapshotLoad = null;
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async readComputerGeneration() {
|
|
333
|
+
const pin = await this.policyDirectory();
|
|
334
|
+
const agentId = this.ctx.id.name;
|
|
335
|
+
return pin === null || agentId === undefined
|
|
336
|
+
? null
|
|
337
|
+
: this.env.AGENT_DIRECTORY.getByName(pin.directoryName).agentComputerRevision(agentId, pin.tenantId);
|
|
338
|
+
}
|
|
339
|
+
async readComputerSnapshot() {
|
|
340
|
+
const pin = await this.policyDirectory();
|
|
341
|
+
const agentId = this.ctx.id.name;
|
|
342
|
+
return pin === null || agentId === undefined
|
|
343
|
+
? null
|
|
344
|
+
: this.env.AGENT_DIRECTORY.getByName(pin.directoryName).agentComputerSnapshot(agentId, pin.tenantId);
|
|
345
|
+
}
|
|
181
346
|
async ui() {
|
|
182
347
|
const { Knapsack } = await import("@fungi.computer/knapsack");
|
|
183
348
|
return (this.uiOwner ??= Knapsack.ui({
|
|
@@ -186,9 +351,16 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
186
351
|
}));
|
|
187
352
|
}
|
|
188
353
|
hostFetch(request) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
354
|
+
const pathname = new URL(request.url).pathname;
|
|
355
|
+
if (pathname.startsWith("/browser/"))
|
|
356
|
+
return this.ui().then((connection) => connection.fetch(request));
|
|
357
|
+
// The Home command reads attachments inside a synchronous state transaction.
|
|
358
|
+
// Prepare its directory snapshot before Shiitake enters that transaction.
|
|
359
|
+
if (request.method === "POST" &&
|
|
360
|
+
pathname.startsWith("/sessions/") &&
|
|
361
|
+
pathname.endsWith(`/extensions/${homeContract.id}/commands/home`))
|
|
362
|
+
return this.preparedComputerSnapshot().then(() => this.runtimeOwner().fetch(request));
|
|
363
|
+
return undefined;
|
|
192
364
|
}
|
|
193
365
|
async closeShiitake() {
|
|
194
366
|
try {
|
|
@@ -252,6 +424,8 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
252
424
|
}
|
|
253
425
|
delegationOwner;
|
|
254
426
|
delegation() {
|
|
427
|
+
const outbox = this.mail();
|
|
428
|
+
const mailRoom = outbox.host.mailRoom;
|
|
255
429
|
return (this.delegationOwner ??= new AgentDelegation({
|
|
256
430
|
storage: this.ctx.storage,
|
|
257
431
|
expiresAt: () => Date.now() + 60_000,
|
|
@@ -265,7 +439,10 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
265
439
|
directory: () => this.controlDirectory(),
|
|
266
440
|
deliver: (mail) => this.mail().deliver(mail),
|
|
267
441
|
prepare: () => Promise.resolve(),
|
|
268
|
-
|
|
442
|
+
stageAcceptedNotice: (mail, scopeId) => {
|
|
443
|
+
mailRoom.stageAcceptedNotice(mail, scopeId);
|
|
444
|
+
},
|
|
445
|
+
accept: async (mail, reply, stageAcceptedNotice) => (await this.shiitake()).acceptMail(mail, mail.meta, reply, stageAcceptedNotice),
|
|
269
446
|
}));
|
|
270
447
|
}
|
|
271
448
|
/** Host-only mail admission; an HTTP caller never receives this binding. */
|
|
@@ -300,6 +477,10 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
300
477
|
const pin = await this.policyDirectory();
|
|
301
478
|
if (pin === null || mail.reply === undefined)
|
|
302
479
|
throw new Error("Delegated reply identity is unavailable");
|
|
480
|
+
if (mail.reply.outcome === "undeliverable")
|
|
481
|
+
return undefined;
|
|
482
|
+
if (mail.from.sessionId === undefined)
|
|
483
|
+
throw new Error("Delegated reply Session is unavailable");
|
|
303
484
|
return (await this.machineClient()).settlement(mail.from.agentId, mail.from.sessionId, mail.reply.commandId, mail.to.agentId);
|
|
304
485
|
},
|
|
305
486
|
wake: () => this.wakeRuntime(),
|
|
@@ -334,36 +515,81 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
334
515
|
? this.hostFor(scope)
|
|
335
516
|
: null;
|
|
336
517
|
}
|
|
337
|
-
|
|
518
|
+
choice() {
|
|
519
|
+
return agentModelChoice(this.ctx.storage, (choice) => {
|
|
520
|
+
if (choice.keySource === "ai-gateway" &&
|
|
521
|
+
configuredProviderEgress(this.env).baseUrl("ai-gateway") === null)
|
|
522
|
+
throw new AgentModelRefused("key_not_connected");
|
|
523
|
+
if (choice.keySource === "workers-ai" && this.env.AI === undefined)
|
|
524
|
+
throw new AgentModelRefused("key_not_connected");
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/** Secret-free default of this Agent; stored separately from Session history. */
|
|
528
|
+
async readModelChoice() {
|
|
529
|
+
return this.choice().read();
|
|
530
|
+
}
|
|
531
|
+
async setModelChoice(input) {
|
|
532
|
+
try {
|
|
533
|
+
return await this.choice().set(input);
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
// DO RPC does not preserve Error prototypes. Transport a domain
|
|
537
|
+
// refusal as data, not an exception the root must recognize by name.
|
|
538
|
+
if (error instanceof AgentModelRefused)
|
|
539
|
+
return { error: error.reason };
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** Capture one model and credential source when the Agent's run acquires it. */
|
|
338
544
|
modelSource() {
|
|
339
|
-
|
|
545
|
+
return {
|
|
546
|
+
acquire: async (input) => {
|
|
547
|
+
input.signal.throwIfAborted();
|
|
548
|
+
const choice = await this.readModelChoice();
|
|
549
|
+
const preference = input.preference;
|
|
550
|
+
if (preference !== null &&
|
|
551
|
+
(preference.providerId !== choice.model.providerId ||
|
|
552
|
+
preference.modelId !== choice.model.modelId))
|
|
553
|
+
throw new AgentModelRefused("key_source_mismatch");
|
|
554
|
+
const selected = {
|
|
555
|
+
...choice,
|
|
556
|
+
model: {
|
|
557
|
+
...choice.model,
|
|
558
|
+
reasoning: preference?.reasoning ?? choice.model.reasoning,
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
const source = await this.sourceFor(selected);
|
|
562
|
+
return source.acquire(input);
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
async sourceFor(choice) {
|
|
567
|
+
const modelChoice = await selectAgentModel(choice);
|
|
568
|
+
if (choice.keySource === "ai-gateway") {
|
|
340
569
|
const egress = configuredProviderEgress(this.env);
|
|
341
570
|
const baseUrl = egress.baseUrl("ai-gateway");
|
|
342
571
|
if (baseUrl === null)
|
|
343
|
-
throw new
|
|
572
|
+
throw new AgentModelRefused("key_not_connected");
|
|
344
573
|
return {
|
|
345
574
|
acquire: async (input) => {
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
.getModels()
|
|
349
|
-
.find((item) => item.id === "gpt-6-luna");
|
|
350
|
-
if (listed === undefined)
|
|
351
|
-
throw new Error("The OpenAI catalog has no Luna model");
|
|
352
|
-
const model = { ...listed, id: "openai/gpt-6-luna", baseUrl };
|
|
353
|
-
return observedModels(normalizeModel(model), {
|
|
575
|
+
const model = { ...modelChoice, id: "openai/gpt-6-luna", baseUrl };
|
|
576
|
+
const streamOptions = {
|
|
354
577
|
apiKey: "oyster-provider-egress",
|
|
355
578
|
fetch: providerFetch(egress, "ai-gateway"),
|
|
356
|
-
}
|
|
579
|
+
};
|
|
580
|
+
if (choice.model.reasoning !== undefined)
|
|
581
|
+
streamOptions.reasoning = choice.model.reasoning;
|
|
582
|
+
return observedModels(normalizeModel(model), streamOptions).acquire(input);
|
|
357
583
|
},
|
|
358
584
|
};
|
|
359
585
|
}
|
|
360
586
|
if (this.env.AI === undefined)
|
|
361
|
-
throw new
|
|
362
|
-
|
|
363
|
-
if (this.env.OYSTER_AGENT_MODEL !== undefined)
|
|
364
|
-
options.model = this.env.OYSTER_AGENT_MODEL;
|
|
587
|
+
throw new AgentModelRefused("key_not_connected");
|
|
588
|
+
let options = { model: modelChoice.id };
|
|
365
589
|
if (this.env.OYSTER_AI_GATEWAY !== undefined)
|
|
366
|
-
options
|
|
590
|
+
options = { ...options, gateway: this.env.OYSTER_AI_GATEWAY };
|
|
591
|
+
if (choice.model.reasoning !== undefined)
|
|
592
|
+
options = { ...options, reasoning: choice.model.reasoning };
|
|
367
593
|
return workersAiModels(this.env.AI, options);
|
|
368
594
|
}
|
|
369
595
|
/** Host-only initialization; Agent/App transports never receive this directory binding. */
|
|
@@ -378,6 +604,7 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
378
604
|
return;
|
|
379
605
|
}
|
|
380
606
|
await this.ctx.storage.put("app-policy-directory", directory);
|
|
607
|
+
this.invalidateComputerConnections();
|
|
381
608
|
}
|
|
382
609
|
/** Host-only policy pin read, used before an owner can approve tools. */
|
|
383
610
|
async policyDirectory() {
|
|
@@ -434,6 +661,14 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
434
661
|
import("@fungi.computer/knapsack"),
|
|
435
662
|
]);
|
|
436
663
|
const conversationAgents = await this.conversationAgents();
|
|
664
|
+
const computerSource = createAgentComputerSource({
|
|
665
|
+
...this.computerConnections(),
|
|
666
|
+
subscribe: (invalidate) => {
|
|
667
|
+
this.computerInvalidations.add(invalidate);
|
|
668
|
+
return () => this.computerInvalidations.delete(invalidate);
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
const extensions = oysterAgentExtensions(conversationAgents.source(this.controlDirectory()));
|
|
437
672
|
const keeping = agentStoreKeeping(this.ctx.storage, (request) => this.readColdSessions(request));
|
|
438
673
|
const principal = {
|
|
439
674
|
actor: { kind: "agent", agentId: this.ctx.id.name ?? "agent" },
|
|
@@ -443,7 +678,7 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
443
678
|
meta: { schema: FungiMessageMetaSchema },
|
|
444
679
|
...this.mail().host,
|
|
445
680
|
instructions: {
|
|
446
|
-
load: () => Promise.resolve({ revision: "oyster-
|
|
681
|
+
load: () => Promise.resolve({ revision: "oyster-5", text: instructions }),
|
|
447
682
|
},
|
|
448
683
|
tools: {
|
|
449
684
|
sources: [
|
|
@@ -465,6 +700,13 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
465
700
|
return source.acquire(context);
|
|
466
701
|
},
|
|
467
702
|
},
|
|
703
|
+
{
|
|
704
|
+
id: computerSource.id,
|
|
705
|
+
acquire: async (context) => {
|
|
706
|
+
await this.preparedComputerSnapshot();
|
|
707
|
+
return computerSource.acquire(context);
|
|
708
|
+
},
|
|
709
|
+
},
|
|
468
710
|
Knapsack.mcpServers({
|
|
469
711
|
id: "agent-mcp",
|
|
470
712
|
read: async () => {
|
|
@@ -497,7 +739,24 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
497
739
|
],
|
|
498
740
|
sandbox: createCodemodeSandbox(this.env.LOADER, "oyster-workerd"),
|
|
499
741
|
},
|
|
500
|
-
extensions:
|
|
742
|
+
extensions: {
|
|
743
|
+
roster: [
|
|
744
|
+
...extensions.roster,
|
|
745
|
+
homeExtension({
|
|
746
|
+
computers: {
|
|
747
|
+
prepare: async () => {
|
|
748
|
+
await this.preparedComputerSnapshot();
|
|
749
|
+
},
|
|
750
|
+
read: () => readAgentComputerConnections(this.computerConnections()).map(({ name, attachmentId, files }) => ({
|
|
751
|
+
name,
|
|
752
|
+
attachmentId,
|
|
753
|
+
port: files.port,
|
|
754
|
+
})),
|
|
755
|
+
},
|
|
756
|
+
}),
|
|
757
|
+
],
|
|
758
|
+
grants: [...extensions.grants, homeGrant],
|
|
759
|
+
},
|
|
501
760
|
...keeping,
|
|
502
761
|
hostWork: [...this.mail().host.hostWork, ...keeping.hostWork],
|
|
503
762
|
};
|
|
@@ -563,6 +822,8 @@ const visitRow = Schema.Struct({
|
|
|
563
822
|
const counterRow = Schema.Struct({ value: Schema.Number });
|
|
564
823
|
/** Names this Oyster's agents and remembers which Sessions the member opened. */
|
|
565
824
|
export class AgentDirectory extends DurableObject {
|
|
825
|
+
computers;
|
|
826
|
+
computerNames = new Map();
|
|
566
827
|
directory;
|
|
567
828
|
avatars;
|
|
568
829
|
composedTenancy;
|
|
@@ -711,10 +972,187 @@ export class AgentDirectory extends DurableObject {
|
|
|
711
972
|
super(ctx, env);
|
|
712
973
|
this.directory = createAgentDirectory(ctx.storage.sql);
|
|
713
974
|
this.avatars = createAgentAvatarStore(ctx.storage.sql);
|
|
975
|
+
this.computers = createAgentComputers({
|
|
976
|
+
sql: ctx.storage.sql,
|
|
977
|
+
transactionSync: (body) => ctx.storage.transactionSync(body),
|
|
978
|
+
readComputerName: (computerId) => {
|
|
979
|
+
const name = this.computerNames.get(computerId);
|
|
980
|
+
if (name === undefined)
|
|
981
|
+
throw new FleetGrantRefused();
|
|
982
|
+
return name;
|
|
983
|
+
},
|
|
984
|
+
revoke: (_requestId, attachment) => {
|
|
985
|
+
ctx.waitUntil(env.AGENTS.getByName(attachment.agentId).invalidateComputerConnections());
|
|
986
|
+
},
|
|
987
|
+
enqueueProjection: (agentId) => {
|
|
988
|
+
this.bump();
|
|
989
|
+
ctx.waitUntil(env.AGENTS.getByName(agentId).invalidateComputerConnections());
|
|
990
|
+
},
|
|
991
|
+
});
|
|
714
992
|
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS visits (agent_id TEXT NOT NULL, session_id TEXT NOT NULL, visited_at INTEGER NOT NULL, PRIMARY KEY (agent_id, session_id)) STRICT");
|
|
715
993
|
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL) STRICT");
|
|
716
994
|
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS app_tool_policies (agent_id TEXT PRIMARY KEY, policy TEXT NOT NULL) STRICT");
|
|
717
995
|
}
|
|
996
|
+
async computerOwner(agentId, tenantId, principal) {
|
|
997
|
+
const scope = tenantScope(tenantId, this.env.OYSTER_ORIGIN);
|
|
998
|
+
if (this.ctx.id.name !== scope.durableName("agents"))
|
|
999
|
+
return null;
|
|
1000
|
+
if (!this.has(agentId))
|
|
1001
|
+
return null;
|
|
1002
|
+
const creator = this.readCreator(agentId);
|
|
1003
|
+
if (creator === null)
|
|
1004
|
+
return null;
|
|
1005
|
+
const member = await this.currentMember(tenantId, creator);
|
|
1006
|
+
if (member === null || !oysterPrincipalIsOwner(member))
|
|
1007
|
+
return null;
|
|
1008
|
+
if (principal !== undefined &&
|
|
1009
|
+
(!oysterPrincipalIsOwner(principal) ||
|
|
1010
|
+
oysterPrincipalId(principal) !== creator))
|
|
1011
|
+
return null;
|
|
1012
|
+
return member;
|
|
1013
|
+
}
|
|
1014
|
+
async loadComputerNames(agentId, tenantId, memberId, extra) {
|
|
1015
|
+
const scope = tenantScope(tenantId, this.env.OYSTER_ORIGIN);
|
|
1016
|
+
const bindings = this.env;
|
|
1017
|
+
const registry = bindings.REGISTRY.getByName(scope.durableName("registry"));
|
|
1018
|
+
const ids = new Set(this.computers.attachedComputers(agentId));
|
|
1019
|
+
if (extra !== undefined)
|
|
1020
|
+
ids.add(extra);
|
|
1021
|
+
for (const computerId of ids) {
|
|
1022
|
+
const admitted = await registry.computerFleet(memberId, {
|
|
1023
|
+
operation: "resolve",
|
|
1024
|
+
computerId,
|
|
1025
|
+
});
|
|
1026
|
+
if (admitted === null ||
|
|
1027
|
+
!("computerId" in admitted) ||
|
|
1028
|
+
admitted.computerId !== computerId)
|
|
1029
|
+
return false;
|
|
1030
|
+
const computer = computerId === this.env.OYSTER_COMPUTER_ID
|
|
1031
|
+
? { name: computerId }
|
|
1032
|
+
: await registry.computerFleet(memberId, {
|
|
1033
|
+
operation: "read",
|
|
1034
|
+
computerId,
|
|
1035
|
+
});
|
|
1036
|
+
if (computer === null || !("name" in computer))
|
|
1037
|
+
return false;
|
|
1038
|
+
this.computerNames.set(computerId, computer.name);
|
|
1039
|
+
}
|
|
1040
|
+
return true;
|
|
1041
|
+
}
|
|
1042
|
+
/** Check the existing transactional revision without resolving fleet names. */
|
|
1043
|
+
async agentComputerRevision(agentId, tenantId = defaultTenant) {
|
|
1044
|
+
return (await this.computerOwner(agentId, tenantId)) === null
|
|
1045
|
+
? null
|
|
1046
|
+
: this.computers.revision(agentId);
|
|
1047
|
+
}
|
|
1048
|
+
/** Read the shared owner only after current membership and real fleet lookup. */
|
|
1049
|
+
async agentComputerSnapshot(agentId, tenantId = defaultTenant) {
|
|
1050
|
+
if (this.computers.attachedComputers(agentId).length === 0)
|
|
1051
|
+
return null;
|
|
1052
|
+
const owner = await this.computerOwner(agentId, tenantId);
|
|
1053
|
+
if (owner === null ||
|
|
1054
|
+
!(await this.loadComputerNames(agentId, tenantId, oysterPrincipalId(owner))))
|
|
1055
|
+
return null;
|
|
1056
|
+
return {
|
|
1057
|
+
scopeId: tenantScope(tenantId, this.env.OYSTER_ORIGIN).durableName("agents"),
|
|
1058
|
+
agentId,
|
|
1059
|
+
projection: this.computers.read(agentId),
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
async computerCommand(operation, agentId, principal, tenantId, command) {
|
|
1063
|
+
const owner = await this.computerOwner(agentId, tenantId, principal);
|
|
1064
|
+
if (owner === null)
|
|
1065
|
+
return { kind: "refused", error: "not_granted" };
|
|
1066
|
+
if (!(await this.loadComputerNames(agentId, tenantId, oysterPrincipalId(owner), operation === "attach" ? command?.computerId : undefined)))
|
|
1067
|
+
return { kind: "refused", error: "not_found" };
|
|
1068
|
+
try {
|
|
1069
|
+
const projection = operation === "read"
|
|
1070
|
+
? this.computers.read(agentId)
|
|
1071
|
+
: operation === "attach" && command !== undefined
|
|
1072
|
+
? this.computers.attachComputer(command)
|
|
1073
|
+
: operation === "detach" && command !== undefined
|
|
1074
|
+
? this.computers.detachComputer(command)
|
|
1075
|
+
: null;
|
|
1076
|
+
if (projection === null)
|
|
1077
|
+
throw new Error("Missing attachment command");
|
|
1078
|
+
return { kind: "ok", projection };
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
const refusal = z
|
|
1082
|
+
.object({
|
|
1083
|
+
code: z.enum([
|
|
1084
|
+
"request_conflict",
|
|
1085
|
+
"attachment_identity_conflict",
|
|
1086
|
+
"not_attached",
|
|
1087
|
+
]),
|
|
1088
|
+
})
|
|
1089
|
+
.safeParse(error);
|
|
1090
|
+
if (!refusal.success)
|
|
1091
|
+
throw error;
|
|
1092
|
+
return {
|
|
1093
|
+
kind: "refused",
|
|
1094
|
+
error: refusal.data.code === "not_attached"
|
|
1095
|
+
? "not_found"
|
|
1096
|
+
: refusal.data.code,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
/** Owner attachment commands retain the shared owner's receipts and versions. */
|
|
1101
|
+
attachComputer(command, principal, tenantId = defaultTenant) {
|
|
1102
|
+
return this.computerCommand("attach", command.agentId, principal, tenantId, command);
|
|
1103
|
+
}
|
|
1104
|
+
/** Detach advances the owner version and invalidates the Agent's held discovery. */
|
|
1105
|
+
detachComputer(command, principal, tenantId = defaultTenant) {
|
|
1106
|
+
return this.computerCommand("detach", command.agentId, principal, tenantId, command);
|
|
1107
|
+
}
|
|
1108
|
+
/** The owner HTTP read uses the same projection as direct discovery. */
|
|
1109
|
+
readAgentComputers(agentId, principal, tenantId = defaultTenant) {
|
|
1110
|
+
return this.computerCommand("read", agentId, principal, tenantId);
|
|
1111
|
+
}
|
|
1112
|
+
/** Every captured native call rechecks the exact live attachment and credential owner. */
|
|
1113
|
+
async admitAgentComputer(captured, tenantId) {
|
|
1114
|
+
const owner = await this.computerOwner(captured.agentId, tenantId);
|
|
1115
|
+
const scope = tenantScope(tenantId, this.env.OYSTER_ORIGIN);
|
|
1116
|
+
const held = captured.attachment;
|
|
1117
|
+
if (owner === null ||
|
|
1118
|
+
captured.scopeId !== scope.durableName("agents") ||
|
|
1119
|
+
!this.computers
|
|
1120
|
+
.attachments()
|
|
1121
|
+
.some((current) => current.agentId === captured.agentId &&
|
|
1122
|
+
current.attachmentId === held.attachmentId &&
|
|
1123
|
+
current.computerId === held.computerId &&
|
|
1124
|
+
current.version === held.version))
|
|
1125
|
+
throw new FleetGrantRefused();
|
|
1126
|
+
const bindings = this.env;
|
|
1127
|
+
const computer = await bindings.REGISTRY.getByName(scope.durableName("registry")).computerFleet(oysterPrincipalId(owner), {
|
|
1128
|
+
operation: "resolve",
|
|
1129
|
+
computerId: held.computerId,
|
|
1130
|
+
});
|
|
1131
|
+
if (computer === null ||
|
|
1132
|
+
!("computerId" in computer) ||
|
|
1133
|
+
computer.computerId !== held.computerId)
|
|
1134
|
+
throw new FleetGrantRefused();
|
|
1135
|
+
return {
|
|
1136
|
+
computerId: held.computerId,
|
|
1137
|
+
actions: new Set([
|
|
1138
|
+
"files.read",
|
|
1139
|
+
"files.readBytes",
|
|
1140
|
+
"files.list",
|
|
1141
|
+
"files.glob",
|
|
1142
|
+
"files.grep",
|
|
1143
|
+
"files.edit",
|
|
1144
|
+
"files.write",
|
|
1145
|
+
"mkdir",
|
|
1146
|
+
"bash",
|
|
1147
|
+
"exec",
|
|
1148
|
+
"processStart",
|
|
1149
|
+
"processList",
|
|
1150
|
+
"processKill",
|
|
1151
|
+
]),
|
|
1152
|
+
expiresAt: Date.now() + 60_000,
|
|
1153
|
+
provenance: { actor: oysterPrincipalId(owner), via: captured.agentId },
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
718
1156
|
list() {
|
|
719
1157
|
return this.directory
|
|
720
1158
|
.list(this.grant())
|
|
@@ -1019,6 +1457,24 @@ export function standaloneAgents(env, scope) {
|
|
|
1019
1457
|
const result = await directory().setAvatar(agentId, avatar, principal);
|
|
1020
1458
|
return "error" in result ? result : toAgent(result);
|
|
1021
1459
|
},
|
|
1460
|
+
readModelChoice: async (agentId) => (await directory().has(agentId))
|
|
1461
|
+
? env.AGENTS.getByName(agentId).readModelChoice()
|
|
1462
|
+
: null,
|
|
1463
|
+
decodeModelChoice: (json) => {
|
|
1464
|
+
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(agentModelChoiceSchema), { onExcessProperty: "error" })(json);
|
|
1465
|
+
return parsed._tag === "None" ? null : parsed.value;
|
|
1466
|
+
},
|
|
1467
|
+
setModelChoice: async (agentId, choice, principal) => {
|
|
1468
|
+
if (!oysterPrincipalIsOwner(principal))
|
|
1469
|
+
return { error: "not_granted" };
|
|
1470
|
+
if (!(await directory().has(agentId)))
|
|
1471
|
+
return { error: "not_found" };
|
|
1472
|
+
const pinned = await env.AGENTS.getByName(agentId).policyDirectory();
|
|
1473
|
+
if (pinned?.directoryName !== directoryName ||
|
|
1474
|
+
pinned.tenantId !== (scope?.tenantId ?? defaultTenant))
|
|
1475
|
+
return { error: "not_found" };
|
|
1476
|
+
return env.AGENTS.getByName(agentId).setModelChoice(choice);
|
|
1477
|
+
},
|
|
1022
1478
|
policyStatus: async (agentId) => directory().policyStatus(agentId),
|
|
1023
1479
|
appToolPolicy: (actor) => directory().appToolPolicy(actor, scope?.tenantId ?? defaultTenant),
|
|
1024
1480
|
policyRevision: (agentId) => directory().policyRevision(agentId),
|