@fungi.computer/oyster 0.3.2 → 0.4.1

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 (86) hide show
  1. package/dist/agent-computer-source.d.ts +42 -0
  2. package/dist/agent-computer-source.js +88 -0
  3. package/dist/agent-computers.d.ts +104 -0
  4. package/dist/agent-computers.js +227 -0
  5. package/dist/agents.d.ts +10 -0
  6. package/dist/agents.js +33 -0
  7. package/dist/app-builds.d.ts +1 -1
  8. package/dist/app-builds.js +16 -6
  9. package/dist/boot-capability.d.ts +2 -1
  10. package/dist/boot-capability.js +7 -5
  11. package/dist/browser/boot.d.ts +2 -2
  12. package/dist/browser/boot.js +10 -3
  13. package/dist/browser/browser.d.ts +1 -1
  14. package/dist/browser/browser.js +2 -1
  15. package/dist/browser/cdp-client.d.ts +2 -0
  16. package/dist/browser/errors.d.ts +2 -2
  17. package/dist/browser/errors.js +2 -2
  18. package/dist/browser/index.d.ts +3 -2
  19. package/dist/browser/index.js +3 -2
  20. package/dist/browser/page.js +11 -4
  21. package/dist/browser/sessions.d.ts +7 -4
  22. package/dist/browser/sessions.js +159 -0
  23. package/dist/browser/url-policy.d.ts +4 -2
  24. package/dist/browser/url-policy.js +4 -2
  25. package/dist/build/build-jobs.d.ts +3 -3
  26. package/dist/build/build-jobs.js +10 -5
  27. package/dist/build/candidate-cli.js +5 -3
  28. package/dist/build/candidate-cli.mjs +223 -5
  29. package/dist/build/candidate-diagnostic.d.ts +2 -0
  30. package/dist/build/candidate-diagnostic.js +16 -0
  31. package/dist/computer-control-port.d.ts +26 -0
  32. package/dist/computer-control-port.js +1 -0
  33. package/dist/host.d.ts +3 -0
  34. package/dist/machine-link/delivery.d.ts +1 -1
  35. package/dist/machine-link/delivery.js +2 -3
  36. package/dist/machine-link/host.d.ts +37 -6
  37. package/dist/machine-link/host.js +209 -14
  38. package/dist/machine-link/link.d.ts +13 -1
  39. package/dist/machine-link/link.js +20 -7
  40. package/dist/mcp.d.ts +144 -10
  41. package/dist/mcp.js +130 -13
  42. package/dist/page/computer-handles.d.ts +3 -3
  43. package/dist/page-script.js +1 -1
  44. package/dist/standalone/agent-computers.d.ts +29 -0
  45. package/dist/standalone/agent-computers.js +101 -0
  46. package/dist/standalone/agent-mail.d.ts +41 -18
  47. package/dist/standalone/agent-model-choice.d.ts +29 -0
  48. package/dist/standalone/agent-model-choice.js +72 -0
  49. package/dist/standalone/agents.d.ts +44 -4
  50. package/dist/standalone/agents.js +502 -46
  51. package/dist/standalone/app-set.d.ts +8 -1
  52. package/dist/standalone/app-set.js +46 -31
  53. package/dist/standalone/computer-control.d.ts +4 -0
  54. package/dist/standalone/computer-control.js +62 -0
  55. package/dist/standalone/computer-fleet.d.ts +1 -0
  56. package/dist/standalone/computer-fleet.js +1 -1
  57. package/dist/standalone/computer.d.ts +21 -2
  58. package/dist/standalone/computer.js +12 -0
  59. package/dist/standalone/install-apps-bin.d.ts +2 -0
  60. package/dist/standalone/install-apps-bin.js +3 -0
  61. package/dist/standalone/install-apps-cli.d.ts +3 -1
  62. package/dist/standalone/install-apps-cli.js +27 -17
  63. package/dist/standalone/machine-link.d.ts +10 -0
  64. package/dist/standalone/machine-link.js +6 -0
  65. package/dist/standalone/registry.d.ts +10 -1
  66. package/dist/standalone/registry.js +9 -0
  67. package/dist/standalone/worker.js +12 -0
  68. package/dist/standalone/workflow-binding.d.ts +2 -2
  69. package/dist/standalone/workflow-host.d.ts +11 -11
  70. package/dist/standalone/workflows.d.ts +22 -22
  71. package/dist/storage-frame.d.ts +3 -3
  72. package/dist/workers-git.d.ts +2 -0
  73. package/dist/workers-git.js +2 -0
  74. package/dist/workers.d.ts +8 -8
  75. package/dist/workers.js +9 -1
  76. package/docs/api/agents.md +66 -0
  77. package/docs/api/apps.md +104 -104
  78. package/docs/api/build-contract.md +35 -35
  79. package/docs/api/build.md +12 -12
  80. package/docs/api/host.md +61 -61
  81. package/docs/api/mcp.md +127 -13
  82. package/docs/api/root-data.md +1 -1
  83. package/docs/api/tenancy.md +1 -1
  84. package/docs/api/workers-git.md +3 -2
  85. package/docs/api/workers.md +81 -83
  86. package/package.json +26 -18
@@ -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
- stream: async (selected, context, options) => {
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 response = await streamSimple(selected, context, {
57
- ...upstream,
58
- fetch: observed.fetch,
59
- onProviderStreamEvent: observed.onProviderStreamEvent,
60
- });
61
- // Observe Pi's ordinary settled-result interface without replacing
62
- // its stream, iterator, result or transport.
63
- void response.result().then((message) => {
64
- if (message.stopReason === "error" &&
65
- !signal.aborted &&
66
- !upstream.signal?.aborted &&
67
- observed.rejection()?.code === "provider_unavailable")
68
- retryableMessages.add(message);
69
- });
70
- 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;
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 ?? defaultAgentModel;
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
- return observedModels(normalizeModel(model), {
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
- return new URL(request.url).pathname.startsWith("/browser/")
190
- ? this.ui().then((connection) => connection.fetch(request))
191
- : 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;
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
- 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),
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
- /** 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. */
338
544
  modelSource() {
339
- 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") {
340
569
  const egress = configuredProviderEgress(this.env);
341
570
  const baseUrl = egress.baseUrl("ai-gateway");
342
571
  if (baseUrl === null)
343
- throw new Error("Configure the Luna AI Gateway Worker secret and gateway");
572
+ throw new AgentModelRefused("key_not_connected");
344
573
  return {
345
574
  acquire: async (input) => {
346
- const { openaiProvider } = await import("@earendil-works/pi-ai/providers/openai");
347
- const listed = openaiProvider()
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
- }).acquire(input);
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 Error("Configure the AI binding to run agents");
362
- const options = {};
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.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 };
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-4", text: instructions }),
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: 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
+ },
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),