@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.
Files changed (75) hide show
  1. package/README.md +2 -1
  2. package/dist/agent-computer-source.d.ts +42 -0
  3. package/dist/agent-computer-source.js +88 -0
  4. package/dist/agent-computers.d.ts +104 -0
  5. package/dist/agent-computers.js +227 -0
  6. package/dist/agents.d.ts +10 -0
  7. package/dist/agents.js +33 -0
  8. package/dist/boot-capability.d.ts +1 -1
  9. package/dist/boot-capability.js +1 -1
  10. package/dist/browser/boot.d.ts +2 -2
  11. package/dist/browser/boot.js +10 -3
  12. package/dist/browser/browser.d.ts +1 -1
  13. package/dist/browser/browser.js +2 -1
  14. package/dist/browser/cdp-client.d.ts +2 -0
  15. package/dist/browser/errors.d.ts +2 -2
  16. package/dist/browser/errors.js +2 -2
  17. package/dist/browser/index.d.ts +3 -2
  18. package/dist/browser/index.js +3 -2
  19. package/dist/browser/page.js +11 -4
  20. package/dist/browser/sessions.d.ts +7 -4
  21. package/dist/browser/sessions.js +159 -0
  22. package/dist/browser/url-policy.d.ts +4 -2
  23. package/dist/browser/url-policy.js +4 -2
  24. package/dist/computer-control-port.d.ts +26 -0
  25. package/dist/computer-control-port.js +1 -0
  26. package/dist/host.d.ts +3 -0
  27. package/dist/machine-link/delivery.d.ts +1 -1
  28. package/dist/machine-link/delivery.js +2 -3
  29. package/dist/machine-link/host.d.ts +37 -6
  30. package/dist/machine-link/host.js +209 -14
  31. package/dist/machine-link/link.d.ts +13 -1
  32. package/dist/machine-link/link.js +20 -7
  33. package/dist/mcp.d.ts +152 -11
  34. package/dist/mcp.js +131 -13
  35. package/dist/page/computer-handles.d.ts +3 -3
  36. package/dist/page-script.js +1 -1
  37. package/dist/standalone/agent-computers.d.ts +29 -0
  38. package/dist/standalone/agent-computers.js +101 -0
  39. package/dist/standalone/agent-mail.d.ts +41 -18
  40. package/dist/standalone/agent-model-choice.d.ts +26 -0
  41. package/dist/standalone/agent-model-choice.js +72 -0
  42. package/dist/standalone/agents.d.ts +50 -5
  43. package/dist/standalone/agents.js +505 -53
  44. package/dist/standalone/app-builds-binding.js +1 -1
  45. package/dist/standalone/computer-control.d.ts +4 -0
  46. package/dist/standalone/computer-control.js +62 -0
  47. package/dist/standalone/computer-fleet.d.ts +1 -0
  48. package/dist/standalone/computer-fleet.js +1 -1
  49. package/dist/standalone/computer.d.ts +21 -2
  50. package/dist/standalone/computer.js +12 -0
  51. package/dist/standalone/machine-link.d.ts +10 -0
  52. package/dist/standalone/machine-link.js +6 -0
  53. package/dist/standalone/registry.d.ts +12 -0
  54. package/dist/standalone/registry.js +18 -0
  55. package/dist/standalone/tenancy.d.ts +14 -1
  56. package/dist/standalone/tenancy.js +37 -3
  57. package/dist/standalone/worker.js +36 -9
  58. package/dist/standalone/workflow-binding.d.ts +2 -2
  59. package/dist/standalone/workflow-host.d.ts +11 -11
  60. package/dist/standalone/workflows.d.ts +22 -22
  61. package/dist/storage-frame.d.ts +3 -3
  62. package/dist/workers-git.d.ts +2 -0
  63. package/dist/workers-git.js +11 -1
  64. package/dist/workers.d.ts +8 -8
  65. package/docs/api/agents.md +66 -0
  66. package/docs/api/apps.md +104 -104
  67. package/docs/api/build-contract.md +35 -35
  68. package/docs/api/build.md +12 -12
  69. package/docs/api/host.md +61 -61
  70. package/docs/api/mcp.md +141 -13
  71. package/docs/api/root-data.md +1 -1
  72. package/docs/api/tenancy.md +1 -1
  73. package/docs/api/workers-git.md +3 -2
  74. package/docs/api/workers.md +81 -83
  75. 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: async ({ signal }) => {
45
- const streamSimple = await loadStreamSimple();
51
+ acquire: ({ signal }) => {
46
52
  const retryableMessages = new WeakSet();
47
- return {
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 response = streamSimple(selected, context, {
56
- ...upstream,
57
- fetch: observed.fetch,
58
- onProviderStreamEvent: observed.onProviderStreamEvent,
59
- });
60
- // Observe Pi's ordinary settled-result interface without replacing
61
- // its stream, iterator, result or transport.
62
- void response.result().then((message) => {
63
- if (message.stopReason === "error" &&
64
- !signal.aborted &&
65
- !upstream.signal?.aborted &&
66
- observed.rejection()?.code === "provider_unavailable")
67
- retryableMessages.add(message);
68
- });
69
- return response;
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 ?? defaultAgentModel;
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
- return observedModels(model, {
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
- return new URL(request.url).pathname.startsWith("/browser/")
194
- ? this.ui().then((connection) => connection.fetch(request))
195
- : undefined;
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
- accept: async (mail, reply) => (await this.shiitake()).acceptMail(mail, mail.meta, reply),
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
- /** Choose this agent's model door; override to plug in another. */
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
- if (this.env.OYSTER_AGENT_MODEL === "@fungi/luna") {
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 Error("Configure the Luna AI Gateway Worker secret and gateway");
572
+ throw new AgentModelRefused("key_not_connected");
348
573
  return {
349
574
  acquire: async (input) => {
350
- const { openaiProvider } = await import("@earendil-works/pi-ai/providers/openai");
351
- const listed = openaiProvider()
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
- }).acquire(input);
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 Error("Configure the AI binding to run agents");
366
- const options = {};
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.gateway = this.env.OYSTER_AI_GATEWAY;
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-4", text: instructions }),
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: oysterAgentExtensions(conversationAgents.source(this.controlDirectory())),
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),