Skip to content

Add createExternalResource agent tool - #427

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

Add createExternalResource agent tool#427
ndisidore wants to merge 12 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.

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

@ask-bonk

This comment was marked as outdated.

@ask-bonk

This comment was marked as outdated.

Base automatically changed from kenton/worktrees to main September 3, 2026 19:22
Vendors advertise creatable resource types via SupportedResource.creatable;
GatekeeperUser.createResource() mints a provisional identity (no provider
call, no user interaction) and returns an imbued gatekeeper class the way
getGatekeeperClassFor() does; Gatekeeper.submitCreationAction() queues the
"create this resource" action so the provider-side creation rides the normal
action-approval flow while the gatekeeper simulates the resource locally.
All three are optional and additive; no existing gatekeeper changes.
Like requestConnection except it creates a brand-new resource of a creatable
type through an already-connected account, and no user action gates the
binding: it lands in the chat's env immediately (the createGadget pattern --
recorded output drives replay and chatScopeNames), the turn continues, and
the creation is an ordinary pending action attributed to the chat, so its
card rides the existing consumeCapturedActions splice.

* agent.ts: tool definition, AgentHooks.createExternalResource, replay case,
  a system-prompt sentence. Fixable rejections (bad name, unknown vendor or
  type, no usable account, ambiguous accounts) return non-error tool results
  so the model retries in-turn; with several connected accounts the message
  enumerates ids so the agent can retry with accountId or ask the user.
* user.ts: createResourceGatekeeper(), the urlPattern-to-capability
  chokepoint for creations, applying the same admin disable-set checks as
  getGatekeeperClassFor().
* overseer.ts: the hook implementation (vendor/creatable validation, mint,
  addGatekeeper, submitCreationAction through an ApprovalQueueImpl scoped to
  the new workpiece and the creating chat, with removeGatekeeper on failure);
  GatekeeperRecord.provisional drives a one-shot describe refresh (guarded on
  the URL changing, since an invalidated edit can apply before the creation) in
  applyPendingAction once the creation applies, since nothing else ever
  re-denormalizes resourceTitle/resourceUrl; listConnectableResources
  annotates creatable types.
* ChatInterface.tsx: the six exhaustive tool-display switch cases.
* integration tests: the fixture gatekeeper grows a creatable type with
  provisional URLs and a rejection-kills-the-binding path; new end-to-end
  coverage for fixable retry, pre-approval use, the action card, the
  describe refresh after approval, replay across turns, and rejection.
… scan.

buildCompactionState folded createGadget/createWorktree outputs into the
checkpoint's chatBindings but not createExternalResource, so once compaction
crossed the creation call the binding replay re-establishes from the recorded
output silently vanished. prepareChatBindings' naming chokepoint had the same
gap: the quick-model namer could hand a pasted capsule a name replay would
bind to a created resource. Both now mirror the replay semantics: a structured
output binds, a string (rejection) output only reserves the name.
…owner.

createExternalResource resolved the workspace owner's user DO, so a
collaborator-driven turn enumerated the owner's connected accounts (the
ambiguous-accounts error lists ids and names into the transcript) and created
resources under the owner's credentials. Follow the actor-scoped pattern of
listAvailableBlueprints: the hook now takes the turn's initiator and resolves
their user DO. Owner-initiated turns are behavior-identical. The vendor
listing stays owner-scoped -- it exposes only static vendor classes.
createExternalResource makes the gatekeeper record and its pending creation
action durable immediately, but the tool call reaches the chat log only at
commitAgentStep -- a DO restart mid-turn left an orphan gatekeeper plus an
approvable creation action bound to nothing, and the resumed turn could mint
a duplicate. Mirror the gadget pending lifecycle: stamp
GatekeeperRecord.pending = {chatId} in the same put as `provisional`, clear
it at the step barrier in addChatMessages (same transaction as the message
write), and sweep unstamped markers from reconcilePendingGadgets' schedule
and chat deletion. The sweep keeps a gatekeeper whose creation was actually
applied (approved-action fallback covers a stuck `provisional` from the
best-effort describe refresh); otherwise it rejects the queued actions and
removes the workpiece in one durable step.
- createResourceGatekeeper: check the admin disable-set after the vendor RPC
  against the resolved resource.urlPattern, mirroring getGatekeeperClassFor
  (the vendor is the authority on which type a request maps to).
- applyPendingAction's post-apply refresh: re-read the gatekeeper record after
  the describe() await so a concurrent removeGatekeeper isn't resurrected by
  the stale put, and refresh creationSpec.resourceUrl too so blueprint export
  with suggestValue doesn't export the dead provisional URL.
- ChatInterface: label a rejected creation "Tried to create external resource"
  instead of claiming it was created.
- submitCreationAction doc: ordering is the gatekeeper's responsibility --
  manual approval has no platform-side ordering guard, so the gatekeeper must
  reject applyAction of dependent actions until the creation applies. The
  test-gatekeeper fixture now models that guard like the Google gatekeeper.
@ndisidore
ndisidore force-pushed the nathan/create-external-resource branch from a37f164 to ccb9d9a Compare September 4, 2026 15:04
@ask-bonk

This comment was marked as outdated.

…ation failure.

Observations and hooks are stamped approved at creation, so without the type
gate a vendor authorizing an observation before its creation action would fake
an approval injection (and first-encounter delete would silence the real card).
The gatekeeper row is now read only when the state is approved, the only case
that consumes it. The awaitDecision latch a failed submitCreationAction set is
restored to its prior value: the settled action can never be decided, and
suspending the turn would hide {created: false} from the model. Also: the
transcript row now uses isCreatedResourceSuccess (an errored call rendered
"Created external resource" beside its Error badge), and the reap comment no
longer claims the action index is resolved-only (pending records index under
their type too; the state check is load-bearing).
The creation card is where replay injects the user's decision; compacting it
away orphaned the decision, so in any sufficiently long chat the recorded
tool result's "does not exist yet" became the model's permanent last word.
Mirror the pending-connectionRequest rule: replay notes the earliest
still-undecided creation card and findProtectedFromSequence keeps the boundary
behind it. Once the decision lands, the next replay injects it before the
projection is built from the same model messages, so the summary absorbs it
and the card compacts normally. Same trade-off as connection requests: an
undecided creation defers compaction until the user decides.
messages: AiChatMessage[], pendingCreationSequence?: number): number | undefined {
let protectedIndex = messages.findIndex(message =>
(message.type === "connectionRequest" && message.state === "pending") ||
(pendingCreationSequence !== undefined && message.sequence >= pendingCreationSequence));

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.

Note for reviewers: now a pending resource create also blocks compaction (following the connectionRequest pattern)

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown

@ndisidore Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

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.

1 participant