@fungi.computer/oyster 0.3.1 → 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/README.md +2 -1
- 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/boot-capability.d.ts +1 -1
- package/dist/boot-capability.js +1 -1
- 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 +152 -11
- package/dist/mcp.js +131 -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 +50 -5
- package/dist/standalone/agents.js +505 -53
- package/dist/standalone/app-builds-binding.js +1 -1
- 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 +12 -0
- package/dist/standalone/registry.js +18 -0
- package/dist/standalone/tenancy.d.ts +14 -1
- package/dist/standalone/tenancy.js +37 -3
- package/dist/standalone/worker.js +36 -9
- 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 +11 -1
- 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 +141 -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeModel, streamModel } from "@fungi.computer/mule/effect";
|
|
1
2
|
import { observeProviderStatus } from "@fungi.computer/mule/effect";
|
|
2
3
|
import { standaloneTenancy, } from "./tenancy.js";
|
|
3
4
|
import { AcceptedSchema } from "@fungi.computer/shiitake/client";
|
|
@@ -17,11 +18,19 @@ import { memberInboxItemSchema } from "@fungi.computer/puffball/inbox";
|
|
|
17
18
|
* Worker's AI binding, with no API token.
|
|
18
19
|
*/
|
|
19
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";
|
|
20
27
|
import { DurableObject } from "cloudflare:workers";
|
|
21
28
|
import { ShiitakeDO } from "@fungi.computer/shiitake/cloudflare";
|
|
22
29
|
import { agentStoreKeeping } from "./agent-store.js";
|
|
23
30
|
import { standaloneBrowserSource } from "./browser.js";
|
|
24
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";
|
|
25
34
|
import { CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL, createAiBindingFetch, } from "@earendil-works/pi-ai/api/cloudflare-ai-binding";
|
|
26
35
|
import { cloudflareWorkersAIProvider } from "@earendil-works/pi-ai/providers/cloudflare-workers-ai";
|
|
27
36
|
import * as Schema from "effect/Schema";
|
|
@@ -36,40 +45,108 @@ import { oysterPrincipalIsOwner, oysterPrincipalId, } from "../host.js";
|
|
|
36
45
|
import { defaultTenant, tenantIdSchema, tenantScope, } from "../tenant.js";
|
|
37
46
|
import { createCodemodeSandbox } from "@fungi.computer/mycelium/workers";
|
|
38
47
|
import { maxAgentMcpServers, } from "../agents.js";
|
|
39
|
-
/** Default Workers AI chat model: one that reliably calls tools. */
|
|
40
|
-
const defaultAgentModel = "@cf/moonshotai/kimi-k2.6";
|
|
41
48
|
/** Own transport evidence and exact-message decisions for one model lease. */
|
|
42
49
|
function observedModels(model, defaults) {
|
|
43
50
|
return {
|
|
44
|
-
acquire:
|
|
45
|
-
const streamSimple = await loadStreamSimple();
|
|
51
|
+
acquire: ({ signal }) => {
|
|
46
52
|
const retryableMessages = new WeakSet();
|
|
47
|
-
|
|
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
|
+
};
|
|
74
|
+
return Promise.resolve({
|
|
48
75
|
model,
|
|
49
76
|
retryable: (message) => !signal.aborted &&
|
|
50
77
|
message.stopReason === "error" &&
|
|
51
78
|
retryableMessages.has(message),
|
|
79
|
+
failureReason: (message) => safeFailures.get(message),
|
|
52
80
|
stream: (selected, context, options) => {
|
|
53
81
|
const upstream = { ...options, ...defaults };
|
|
54
82
|
const observed = observeProviderStatus(upstream);
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
|
70
147
|
},
|
|
71
148
|
release: () => Promise.resolve(),
|
|
72
|
-
};
|
|
149
|
+
});
|
|
73
150
|
},
|
|
74
151
|
};
|
|
75
152
|
}
|
|
@@ -77,8 +154,8 @@ function observedModels(model, defaults) {
|
|
|
77
154
|
* Workers AI over the AI binding. Requests go through AI Gateway's
|
|
78
155
|
* `workers-ai` passthrough on the binding, so no API token is needed.
|
|
79
156
|
*/
|
|
80
|
-
function workersAiModels(ai, options
|
|
81
|
-
const id = options.model
|
|
157
|
+
function workersAiModels(ai, options) {
|
|
158
|
+
const id = options.model;
|
|
82
159
|
const listed = cloudflareWorkersAIProvider()
|
|
83
160
|
.getModels()
|
|
84
161
|
.find((model) => model.id === id);
|
|
@@ -89,7 +166,7 @@ function workersAiModels(ai, options = {}) {
|
|
|
89
166
|
baseUrl: `https://workers-binding.ai/ai-gateway/gateways/${options.gateway ?? "default"}/workers-ai/v1`,
|
|
90
167
|
};
|
|
91
168
|
const fetch = createAiBindingFetch(ai);
|
|
92
|
-
|
|
169
|
+
const streamOptions = {
|
|
93
170
|
fetch,
|
|
94
171
|
apiKey: CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL,
|
|
95
172
|
headers: {
|
|
@@ -98,13 +175,11 @@ function workersAiModels(ai, options = {}) {
|
|
|
98
175
|
// taken as a provider key that overrides the gateway's own.
|
|
99
176
|
Authorization: null,
|
|
100
177
|
},
|
|
101
|
-
}
|
|
178
|
+
};
|
|
179
|
+
if (options.reasoning !== undefined)
|
|
180
|
+
streamOptions.reasoning = options.reasoning;
|
|
181
|
+
return observedModels(normalizeModel(model), streamOptions);
|
|
102
182
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Pi's dispatching `streamSimple` evaluates every provider's model catalog when
|
|
105
|
-
* it loads; only a run needs it, so acquisition loads it instead of startup.
|
|
106
|
-
*/
|
|
107
|
-
const loadStreamSimple = async () => (await import("@earendil-works/pi-ai/compat")).streamSimple;
|
|
108
183
|
const policyDirectorySchema = Schema.Struct({
|
|
109
184
|
directoryName: Schema.NonEmptyString,
|
|
110
185
|
tenantId: tenantIdSchema,
|
|
@@ -143,6 +218,7 @@ const toMcpServer = (stored) => ({
|
|
|
143
218
|
hasBearer: stored.bearer !== null,
|
|
144
219
|
});
|
|
145
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.",
|
|
146
222
|
"You are an agent on a personal Oyster: a small, self-hosted home for Apps.",
|
|
147
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({})`.",
|
|
148
224
|
"Remote MCP servers the owner connected are env.mcp.<server> modules; call a tool as `return await env.mcp.<server>.<tool>({...})`.",
|
|
@@ -182,6 +258,91 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
182
258
|
},
|
|
183
259
|
});
|
|
184
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
|
+
}
|
|
185
346
|
async ui() {
|
|
186
347
|
const { Knapsack } = await import("@fungi.computer/knapsack");
|
|
187
348
|
return (this.uiOwner ??= Knapsack.ui({
|
|
@@ -190,9 +351,16 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
190
351
|
}));
|
|
191
352
|
}
|
|
192
353
|
hostFetch(request) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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;
|
|
196
364
|
}
|
|
197
365
|
async closeShiitake() {
|
|
198
366
|
try {
|
|
@@ -256,6 +424,8 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
256
424
|
}
|
|
257
425
|
delegationOwner;
|
|
258
426
|
delegation() {
|
|
427
|
+
const outbox = this.mail();
|
|
428
|
+
const mailRoom = outbox.host.mailRoom;
|
|
259
429
|
return (this.delegationOwner ??= new AgentDelegation({
|
|
260
430
|
storage: this.ctx.storage,
|
|
261
431
|
expiresAt: () => Date.now() + 60_000,
|
|
@@ -269,7 +439,10 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
269
439
|
directory: () => this.controlDirectory(),
|
|
270
440
|
deliver: (mail) => this.mail().deliver(mail),
|
|
271
441
|
prepare: () => Promise.resolve(),
|
|
272
|
-
|
|
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),
|
|
273
446
|
}));
|
|
274
447
|
}
|
|
275
448
|
/** Host-only mail admission; an HTTP caller never receives this binding. */
|
|
@@ -304,6 +477,10 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
304
477
|
const pin = await this.policyDirectory();
|
|
305
478
|
if (pin === null || mail.reply === undefined)
|
|
306
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");
|
|
307
484
|
return (await this.machineClient()).settlement(mail.from.agentId, mail.from.sessionId, mail.reply.commandId, mail.to.agentId);
|
|
308
485
|
},
|
|
309
486
|
wake: () => this.wakeRuntime(),
|
|
@@ -338,36 +515,81 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
338
515
|
? this.hostFor(scope)
|
|
339
516
|
: null;
|
|
340
517
|
}
|
|
341
|
-
|
|
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. */
|
|
342
544
|
modelSource() {
|
|
343
|
-
|
|
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") {
|
|
344
569
|
const egress = configuredProviderEgress(this.env);
|
|
345
570
|
const baseUrl = egress.baseUrl("ai-gateway");
|
|
346
571
|
if (baseUrl === null)
|
|
347
|
-
throw new
|
|
572
|
+
throw new AgentModelRefused("key_not_connected");
|
|
348
573
|
return {
|
|
349
574
|
acquire: async (input) => {
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
.getModels()
|
|
353
|
-
.find((item) => item.id === "gpt-6-luna");
|
|
354
|
-
if (listed === undefined)
|
|
355
|
-
throw new Error("The OpenAI catalog has no Luna model");
|
|
356
|
-
const model = { ...listed, id: "openai/gpt-6-luna", baseUrl };
|
|
357
|
-
return observedModels(model, {
|
|
575
|
+
const model = { ...modelChoice, id: "openai/gpt-6-luna", baseUrl };
|
|
576
|
+
const streamOptions = {
|
|
358
577
|
apiKey: "oyster-provider-egress",
|
|
359
578
|
fetch: providerFetch(egress, "ai-gateway"),
|
|
360
|
-
}
|
|
579
|
+
};
|
|
580
|
+
if (choice.model.reasoning !== undefined)
|
|
581
|
+
streamOptions.reasoning = choice.model.reasoning;
|
|
582
|
+
return observedModels(normalizeModel(model), streamOptions).acquire(input);
|
|
361
583
|
},
|
|
362
584
|
};
|
|
363
585
|
}
|
|
364
586
|
if (this.env.AI === undefined)
|
|
365
|
-
throw new
|
|
366
|
-
|
|
367
|
-
if (this.env.OYSTER_AGENT_MODEL !== undefined)
|
|
368
|
-
options.model = this.env.OYSTER_AGENT_MODEL;
|
|
587
|
+
throw new AgentModelRefused("key_not_connected");
|
|
588
|
+
let options = { model: modelChoice.id };
|
|
369
589
|
if (this.env.OYSTER_AI_GATEWAY !== undefined)
|
|
370
|
-
options
|
|
590
|
+
options = { ...options, gateway: this.env.OYSTER_AI_GATEWAY };
|
|
591
|
+
if (choice.model.reasoning !== undefined)
|
|
592
|
+
options = { ...options, reasoning: choice.model.reasoning };
|
|
371
593
|
return workersAiModels(this.env.AI, options);
|
|
372
594
|
}
|
|
373
595
|
/** Host-only initialization; Agent/App transports never receive this directory binding. */
|
|
@@ -382,6 +604,7 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
382
604
|
return;
|
|
383
605
|
}
|
|
384
606
|
await this.ctx.storage.put("app-policy-directory", directory);
|
|
607
|
+
this.invalidateComputerConnections();
|
|
385
608
|
}
|
|
386
609
|
/** Host-only policy pin read, used before an owner can approve tools. */
|
|
387
610
|
async policyDirectory() {
|
|
@@ -438,6 +661,14 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
438
661
|
import("@fungi.computer/knapsack"),
|
|
439
662
|
]);
|
|
440
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()));
|
|
441
672
|
const keeping = agentStoreKeeping(this.ctx.storage, (request) => this.readColdSessions(request));
|
|
442
673
|
const principal = {
|
|
443
674
|
actor: { kind: "agent", agentId: this.ctx.id.name ?? "agent" },
|
|
@@ -447,7 +678,7 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
447
678
|
meta: { schema: FungiMessageMetaSchema },
|
|
448
679
|
...this.mail().host,
|
|
449
680
|
instructions: {
|
|
450
|
-
load: () => Promise.resolve({ revision: "oyster-
|
|
681
|
+
load: () => Promise.resolve({ revision: "oyster-5", text: instructions }),
|
|
451
682
|
},
|
|
452
683
|
tools: {
|
|
453
684
|
sources: [
|
|
@@ -469,6 +700,13 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
469
700
|
return source.acquire(context);
|
|
470
701
|
},
|
|
471
702
|
},
|
|
703
|
+
{
|
|
704
|
+
id: computerSource.id,
|
|
705
|
+
acquire: async (context) => {
|
|
706
|
+
await this.preparedComputerSnapshot();
|
|
707
|
+
return computerSource.acquire(context);
|
|
708
|
+
},
|
|
709
|
+
},
|
|
472
710
|
Knapsack.mcpServers({
|
|
473
711
|
id: "agent-mcp",
|
|
474
712
|
read: async () => {
|
|
@@ -501,7 +739,24 @@ export class StandaloneAgentBase extends ShiitakeDO {
|
|
|
501
739
|
],
|
|
502
740
|
sandbox: createCodemodeSandbox(this.env.LOADER, "oyster-workerd"),
|
|
503
741
|
},
|
|
504
|
-
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
|
+
},
|
|
505
760
|
...keeping,
|
|
506
761
|
hostWork: [...this.mail().host.hostWork, ...keeping.hostWork],
|
|
507
762
|
};
|
|
@@ -567,6 +822,8 @@ const visitRow = Schema.Struct({
|
|
|
567
822
|
const counterRow = Schema.Struct({ value: Schema.Number });
|
|
568
823
|
/** Names this Oyster's agents and remembers which Sessions the member opened. */
|
|
569
824
|
export class AgentDirectory extends DurableObject {
|
|
825
|
+
computers;
|
|
826
|
+
computerNames = new Map();
|
|
570
827
|
directory;
|
|
571
828
|
avatars;
|
|
572
829
|
composedTenancy;
|
|
@@ -715,10 +972,187 @@ export class AgentDirectory extends DurableObject {
|
|
|
715
972
|
super(ctx, env);
|
|
716
973
|
this.directory = createAgentDirectory(ctx.storage.sql);
|
|
717
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
|
+
});
|
|
718
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");
|
|
719
993
|
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL) STRICT");
|
|
720
994
|
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS app_tool_policies (agent_id TEXT PRIMARY KEY, policy TEXT NOT NULL) STRICT");
|
|
721
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
|
+
}
|
|
722
1156
|
list() {
|
|
723
1157
|
return this.directory
|
|
724
1158
|
.list(this.grant())
|
|
@@ -1023,6 +1457,24 @@ export function standaloneAgents(env, scope) {
|
|
|
1023
1457
|
const result = await directory().setAvatar(agentId, avatar, principal);
|
|
1024
1458
|
return "error" in result ? result : toAgent(result);
|
|
1025
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
|
+
},
|
|
1026
1478
|
policyStatus: async (agentId) => directory().policyStatus(agentId),
|
|
1027
1479
|
appToolPolicy: (actor) => directory().appToolPolicy(actor, scope?.tenantId ?? defaultTenant),
|
|
1028
1480
|
policyRevision: (agentId) => directory().policyRevision(agentId),
|