Skip to content

Add createExternalResource agent tool - #427

Open
ndisidore wants to merge 6 commits into
mainfrom
nathan/create-external-resource
Open

ndisidore wants to merge 6 commits into
mainfrom
nathan/create-external-resource

Conversation

@ndisidore

@ndisidore ndisidore commented Sep 2, 2026

Copy link
Copy Markdown
Member

Adds the createExternalResource tool as discussed with respect to Google Drive: rather than granting whole-Drive access so the agent can make one document, the agent creates a new resource of a creatable type through an already-connected account.

This adds the other half of requestConnection: the agent mints a brand-new resource of a type the vendor marks creatable, through an account the user already connected. The binding is live immediately (simliar to what createGadget does), the turn continues, and the gatekeeper simulates the resource until the user approves the creation, which queues first and applies first, so dependent edits are safe to stack behind it.

🔏 One thing worth noting: thus far we have been following that we use the minting account's credentials, and for creations that account is the turn initiator's. This means a collaborator-driven creation lands in the collaborator's provider account. If we want this to be the gadget owner's we can make that swap.

Auto approval is not supported yet.

Screenshot from 2026-09-07 16-50-55 Screenshot from 2026-09-07 16-53-21

First consumer is Google Doc creation, stacked as #428

@github-actions github-actions Bot added workshop/frontend Changes to the Workshop frontend kernel Changes to the Workshop kernel workshop/shared Changes to shared Workshop APIs labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

Preview: pr427-nathan-create-f79af453

https://pr427-nathan-create-f79af453-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

Base automatically changed from kenton/worktrees to main September 3, 2026 19:22
@ndisidore
ndisidore force-pushed the nathan/create-external-resource branch 2 times, most recently from a5eceb8 to 8604cbd Compare September 4, 2026 23:39
@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidore force-pushed the nathan/create-external-resource branch from 8604cbd to 19d6322 Compare September 7, 2026 19:38
@ask-bonk

This comment was marked as outdated.

const summary = getToolCallSummary(toolCalls[0], outputOf);
// Label by the last call: for same-target retries the final outcome wins (a failed create
// retried successfully is "Created", not "Tried").
const summary = getToolCallSummary(toolCalls[toolCalls.length - 1], outputOf);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so createExternalResource is the one tool where a "failure" is just a normal string result the model reads and retries on
every other tool gets away with a dumb count label because the error machinery covers their failures separately. we can't just set tc.error on rejections either - the tool call itself succeeded, it returned guidance the model is supposed to act on, and flagging that as an error would paint the designed fix-and-retry flow red like something broke.
so if we did "created 2 external resources" when one was actually rejected, the group header would straight up lie about the thing the user is being asked to approve - hence this one case counts successes

open to other options here

@ndisidore
ndisidore force-pushed the nathan/create-external-resource branch from 19d6322 to e9ee3a1 Compare September 7, 2026 20:29
@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidore force-pushed the nathan/create-external-resource branch 3 times, most recently from 0fd3f8c to c0eee25 Compare September 8, 2026 11:53
@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
ask-bonk[bot]

This comment was marked as resolved.

@ndisidore
ndisidore force-pushed the nathan/create-external-resource branch from c0eee25 to 25cb704 Compare September 8, 2026 12:50
ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Sep 8, 2026

Copy link
Copy Markdown

Posted 2 actionable inline findings.

github run

Thread a flat scalar map through createExternalResource that the kernel carries but never reads: the vendor documents accepted keys on the creatable type, validates them, and reflects consequential ones (e.g. placement) on its approval card.
@ndisidore
ndisidore force-pushed the nathan/create-external-resource branch from 25cb704 to 969f2bc Compare September 8, 2026 13:10
@ndisidore
ndisidore marked this pull request as ready for review September 8, 2026 13:10
@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

3 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +5444 to +5448
} catch (error) {
this.logger.warn("failed to refresh created resource description after apply", {
event: "gatekeeper.created.describe.refresh.failed",
gatekeeperId: record.gatekeeperId, error,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Created resource keeps dead URL

If both post-approval describe() attempts fail, the catch leaves provisional set without scheduling another retry. A creation with no later action keeps its dead URL in cards and blueprint exports indefinitely.

Prompt for agents
The post-creation description refresh in packages/workshop-backend/src/overseer.ts only retries immediately once. If both attempts fail and no later action is applied to this gatekeeper, the provisional marker and URL remain durable forever. Add a durable or scheduled retry path that re-reads the gatekeeper record, refreshes its description after the creation action is approved, updates the creation action snapshot and creationSpec URL, and remains safe if the gatekeeper is removed concurrently.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, this is documented best-effort with a bounded cosmetic residual. it requires describe() failing twice, milliseconds after applyAction to the same vendor succeeded

Comment on lines +11670 to +11675
let record = this.impl.storage.gatekeepers.get(action.gatekeeperId);
if (record?.creation?.actionId === id) {
let siblings = Array.from(
this.impl.storage.actions.pendingByGatekeeper.get(action.gatekeeperId))
.filter(sibling => sibling.type === "action");
for (let sibling of siblings) this.#markActionRejected(sibling, profile);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rejected creation leaves broken bindings

When rejection follows a merged gadget binding, rejectAction preserves the gatekeeper and every binding edge. The gadget exposes a nonexistent resource until someone manually removes that binding.

Prompt for agents
In packages/workshop-backend/src/overseer.ts, rejecting a creation retains its gatekeeper so chat replay can explain the dead chat binding. However, permanent gadget binding edges may already target that gatekeeper if chat changes were merged before the action decision. On creation rejection, remove or otherwise invalidate those gadget edges and restart affected gadgets while retaining whatever chat-scoped tombstone is needed for replay. Add coverage for setGadgetBinding, merge, then reject creation.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accepted tradeoff. the outcome is the platform's uniform broken-connection behavior: the gadget's calls fail with the vendor's curated DEAD: … do not retry explanation

@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
// agent should adjust and retry in the same turn.
async createExternalResource(chatId: number, input: CreateExternalResourceInput,
initiator: AiChatAuthorInfo): Promise<CreatedResourceOutput | string> {
let vendors = await this.#listGatekeeperVendorsCached();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: This catalog is cached from the workspace owner, but the mint below deliberately resolves initiator.id. GatekeeperVendor.getSupportedResources({ userId }) is allowed to be user-specific, so a build collaborator can be rejected as unknown/not-creatable solely because the owner cannot see a type that the collaborator and their connected account support (and the inverse can advertise owner-only types). Resolve creation discovery and validation through the initiator’s User DO rather than the owner cache.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now catalogs don't vary per user so this point is moot.

Comment thread packages/workshop-backend/src/overseer.ts
@ask-bonk

ask-bonk Bot commented Sep 8, 2026

Copy link
Copy Markdown

Posted 2 actionable inline findings.

github run

// for as long as the collaborator keeps reconnecting. Quarantine in the same synchronous
// block instead (unverified sessions can't reach the id, see assertGatekeeperUsable) and
// restart once the log backs the creation (addChatMessages' unstamp branch).
if (this.#hasCollaboratorSession("build")) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above has more context on this.

the restart is a full DO abort, but the tool call that recorded this mint isn't durable until the step barrier commits - abort before that and the workspace comes back with no memory of the mint, the resumed turn re-creates it, and you're in a remint loop. so instead in these cases we defer the restart until the call is actually in the log

no collaborator session, no quarantine, no restart - all this gets skipped

@cloudflare cloudflare deleted a comment from ask-bonk Bot Sep 8, 2026
try {
let userStub = wrapDoStubForTelemetry(
this.users.get(this.users.idFromName(initiator.id)), this.logger);
minted = await userStub.createResourceGatekeeper(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging again for reviewers: this goes through the turn initiator’s User DO on purpose, since connected accounts are per-user. a collaborator-driven chat creates the resource with that collaborator’s selected account, while gadget/scheduled turns resolve the initiator to the gadget owner, so the normal scheduled-task case still uses the owner’s account. the returned gatekeeper class is permanently imbued with that account’s credentials - whoever later clicks approve does not swap in their own credentials

I'm not deadset on this approach so I welcome pushback here

* (e.g. its authorization does not cover the needed scopes) or when `input.options` carries
* unknown or invalid entries (see ResourceCreationOptions); callers surface the message.
*/
createResource?(resourceUrlPattern: string,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method is intentionally not the provider-side create call - it only mints a provisional local capability. the real create is queued as the first normal approval action, while the gatekeeper simulates the resource so the agent can keep working and queue dependent edits. that’s what lets something like “create today’s doc, then append to it” happen in one turn without bypassing approval. once that first action is approved, the same gatekeeper starts talking to the real resource instead

/**
* On success, the full tool result: the created gatekeeper workpiece, its provisional
* resourceUrl, and the message shown to the model — replay re-binds and re-renders from it
* instead of re-creating, like createGadget. A string output is a fixable rejection returned

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't a rejection use the common error property instead?

(I'm not sure why requestConnection does it differently -- maybe it should also change to use error.)

* `error` is set on the call), so error-based filters do not catch it. Every consumer that scans
* recorded tool calls must gate on this, not on `output !== undefined`.
*/
export function isCreatedResourceSuccess(output: CreatedResourceOutput | string | undefined)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm dubious this helper is worth defining, but it could definitely go away if we change to use error for errors (then output would be output?: CreatedResourceOutput so could trivially be tested with if (tool.output)).

class: DurableObjectClass<Gatekeeper<any>>;
resource: SupportedResource;
/** Provisional URL of the new resource; replaced by the real URL once created. */
resourceUrl: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the use of a provisional URL? It's fake, you can't actually visit it, and in most cases it won't even match the final URL. Could we just drop this property altogether?

* and removes the gatekeeper WITHOUT delivering rejectAction(): the facet's storage is
* destroyed with it, and any state staged outside the facet must tolerate orphaned entries.
*/
submitCreationAction?(approvalQueue: RpcStub<ApprovalQueue>): Promise<void>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<thinking>
Hmm, as currently designed, ResourceCreationOptions would have to be embedded into the props of the gatekeeper class returned by createResource(). This strikes me as a bit odd -- those props are permanent, but creation options may not be needed anymore after creation time. Perhaps the options should be passed here instead?

I'm also realizing that in the existing design, getGatekeeperClassFor() needs to embed the resource URL into the props. This is problematic because a gatekeeper created by createResource() doesn't necessarily have a URL until finalized, but we would probably like to share the same implementation class between created resources and existing ones... but you can't change the props after the fact.

I wonder if we need to transition the gatekeeper API to be two-step for all gatekeepers, that is, you obtain the class you want first, then you start the facet and call a method on the class to initialize it. When connecting to an existing resource, you'd pass the URL on that method. For creations, you'd pass the ResourceCreationOptions.

Existing gatekeepers that embed their URL into props would need a migration to move the information into local storage instead.

Alternatively, I guess another approach we could take is to keep connecting to existing resources as-is, but for creations, the props starts out containing null values, but when the creation is finalized, there's some API for the facet to return a replacement DurableObjectClass to use from that point forward. So that way it actually is able to embed the URL into the props after the fact.

OK, I guess I like this idea a bit better because it allows read-only gatekeepers to remain totally stateless. (Whereas if we move props to dynamic storage, the facet MUST initialize its sqlite database to store the resource URL at least...)
</thinking>

OK, I think what we actually want here is:

  • Remove this method. I think it's not really needed: The createResource() call could return the appropriate ActionDescription.
  • Add a different method, applyCreation(), to be called to actually create the resource, instead of having applyAction() do it. This new method should return a new version of the DurableObjectClass (and final resourceUrl), and the facet should then be restarted using that new class before anything else happens.

I kind of wonder if applyCreation() should be hooked with an opportunity for the user to be presented with some UI to choose final creation options. E.g. for GitHub, this could give the user a chance to change the repo name and select an org to put it in -- things the agent might not have known if it was given generic instructions to create a repo.

* entries with an agent-readable message, and MUST reflect consequential entries (e.g. placement)
* in the creation action's description so the user approves what will actually happen.
*/
export type ResourceCreationOptions = Record<string, string | number | boolean>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does the agent discover what options are available?

* (e.g. its authorization does not cover the needed scopes) or when `input.options` carries
* unknown or invalid entries (see ResourceCreationOptions); callers surface the message.
*/
createResource?(resourceUrlPattern: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth being extra-clear in the comments that this method does not actually create anything or have any side effects. It just returns a class. You have to call methods on the facet for anything to happen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kernel Changes to the Workshop kernel workshop/frontend Changes to the Workshop frontend workshop/shared Changes to shared Workshop APIs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants