Skip to content

Typed arguments and results for built-in tools, piloted on EventKit - #8

Merged
zac merged 9 commits into
mainfrom
typed-bridges
Oct 23, 2026
Merged

zac merged 9 commits into
mainfrom
typed-bridges

Conversation

@zac

@zac zac commented Oct 23, 2026

Copy link
Copy Markdown
Member

Makes @BuiltInCodeMode actually remove code, and sets the template the other 14 bridge families will copy. EventKit (calendar and reminders) is the pilot.

Why

The macro generated typed Arguments decoding, and then 109 of ~117 tools threw it away: they passed arguments.raw to a bridge that parsed every argument again by string key. Every argument was declared twice, defaults lived in both hint prose and code, and the results were hand-built JSONValue described by an unchecked sentence. Models saw every helper as Promise<CodeModeValue>.

What a tool author writes now

@BuiltInCodeMode(.calendarRead, path: "apple.calendar.listEvents")
struct CalendarListEventsTool: BuiltInCodeModeTool {
    struct Arguments: Sendable {
        @ToolParam("Range start; now when omitted.")
        var start: Date?                 // decoded and validated by the macro
        @ToolParam("Max number of items.")
        var limit: Int = 50              // hint gains "Default: 50."
        …
    }

    func call(arguments: Arguments, context: BridgeInvocationContext) throws -> [CalendarEvent] {
        …                                // the logic, not a forward to a bridge
    }
}

@ToolResult struct CalendarEvent {     // encoder + schema from one declaration
    var identifier: String
    @ToolField("ISO 8601 timestamp") var startDate: Date
    …
}

And what the model sees:

listEvents(args?: { start?: string; limit?: number; … }): Promise<Array<{
  identifier: string;
  /** ISO 8601 timestamp */
  startDate: string;
  …
}>>;

Commits

  • Permissions (4f9f4f6). One implementation of the broker sequence, used by the registry. Tools gain argumentDependentPermissions(for:) for permissions that depend on the call (location read in current mode). An audit found all 29 static declarations correct.
  • Typed EventKit arguments (651e054). Macro defaults as initializers (var limit: Int = 50), Date properties, bridges take typed input.
  • codeModeTitle removed (5b2e90f). 120 titles no model ever saw; derived from the path instead.
  • One tool per helper, Patch<T>, logic in call (b516c56).
    • createEvent/updateEvent and createReminder/updateReminder/completeReminder were aliases on one tool, disambiguated by hand-written JS that injected an operation argument. Now separate tools with correct required arguments. Several tools share one CapabilityID, so the registry keeps a list per ID and dispatch passes the called name through (to the handler and to error suggestions).
    • Scripts could never clear a field: the registry rejected null before any handler ran. Patch<T> (missing / null / value) makes clearing real on update tools, advertised as T | null only where it's true. The macro generates hasChanges(_:) so an empty update is rejected without a hand-kept field list.
    • EventKitBridge.swift is now 149 lines of shared helpers and result structs (544 on main).
  • Typed results (a240d0c). @ToolResult structs, a runtime-derived CodeModeResultSchema with a compact public JSON form, and declarations that render it as the promise type. Unmigrated families keep -> JSONValue and their prose summary, so they compile unchanged.
  • Docs: PLAN-typed-results.md, PLAN-async-bridges.md (the async decision, for the next branch), README install snippet fixed for the existing tags.

For the reviewer

  • Behaviour changes scripts can see, all intended:
    • null now clears a field on the update helpers: notes, location, url and isAllDay on updateEvent; dueDate, notes, isCompleted, priority and the calendar on updateReminder. Before, it was rejected.
    • createEvent with identifier, or updateEvent without one, is now an INVALID_ARGUMENTS error naming the right helper.
    • Write-date errors use the standard repairable date message instead of an EventKit-specific one.
    • Calendar create accepts a custom permission broker that reports .writeOnly for .calendarWriteOnly. The system broker already reported that case as granted.
  • Public API changes: CapabilityRegistry.invoke(_:jsName:arguments:context:) gains an optional jsName; CapabilityDescriptor and JavaScriptAPIReference gain nullableArguments and resultSchema (both decode with defaults, so earlier encodings still load); a host registration now replaces every built-in registration whose JS names it overlaps, or the whole capability when it names none.
  • The JSON results scripts receive are unchanged, including "" for absent strings. Whether those should become null is a deliberate decision left open in TODO.md.
  • The registry change is the part most worth a close look: several registrations per CapabilityID, first-registration semantics for descriptor(for:)/registration(for:), and byCapability in search holding the first helper.
  • Golden baseline: each diff is in the commit that caused it and is the intended review artifact.

Verification

Each commit was reviewed by an independent quality gate (Opus) until it passed; three rounds on the split, two on typed results. On the final head, locally:

  • swift test: 356 + 12 + 32 tests passing
  • scripts/check-typescript-declarations.sh: all 128 generated declarations compile under tsc --strict
  • xcodebuild build for generic/platform=iOS and generic/platform=visionOS: clean

Not in this PR

Migrating the other 14 families (each with its hand-written globalThis.apple.* JS deleted), element types for argument arrays, named interfaces in the whole-surface declarations, and the async bridge work. All recorded in TODO.md and the two plan files.

zac added 9 commits October 21, 2026 16:45
The rewrite said no release was tagged, but 0.1.0 through 0.2.2 are. The
old README had carried the same mistake.
Astra reviewed the options read-only and Fable decided: go truly async, with
the first phase shipping width 1 so only deadlines and cancellation change.
Written down now so the branch after typed-bridges starts from it.
…-dependent ones

The registry checks a tool's statically declared permissions before its
handler runs, and the bridges then checked the same permissions again with
their own copy of the logic. The registry's check, the context's
`resolvedPermission`, and LocationBridge each carried the broker sequence
(status, record, request if undetermined, record).

That sequence now lives in one place, `BridgeInvocationContext`.
`requirePermission` is the enforcing form the registry uses, and it still asks
the broker and records an event on every invocation, as before. A test pins
that, so a later cache is a deliberate change, not a side effect.

Built-in tools gain `argumentDependentPermissions(for:)`, checked after
decoding, for permissions that depend on what the call asks for. Location read
needs location access only in `current` mode. Calendar create vs update follows
with the EventKit migration.

The audit found all 29 static declarations match what their bridges enforce,
and the permission-request helpers declare none, so nothing pre-checks them.
`@BuiltInCodeMode` generated typed decoding, and then almost every tool threw
it away: it handed `arguments.raw` to its bridge, which parsed every argument
again by string key. That meant two declarations per argument, and defaults
kept both in hint prose and in code. This is the pilot that removes the
duplication for one family and sets the template for the rest.

The macro gains:
- Defaults written as Swift initializers. `var limit: Int = 50` makes the
  argument optional, decode falls back to the default, and the advertised hint
  ends with a generated "Default: 50." Only literals and `.enumCase` are
  accepted; anything else, `let`, or an optional with a default is a diagnostic.
- `Date` properties, decoded through `CodeModeDate`, with the same errors the
  bridges produced.
- `providedArguments: Set<String>`, the keys a call supplied, so patch updates
  can tell which fields to touch without keeping raw values.

The six EventKit tools drop `raw`. Their bridge methods take the tool's typed
`Arguments`, and the re-parsing, the operation parsing, and the permission
guards the registry already enforces are deleted. Calendar create and update
choose their permission through `argumentDependentPermissions`. One
intentional change: create now accepts a custom broker that reports
`.writeOnly` for `.calendarWriteOnly`. The system broker already reported
that as granted.

Scripts see the same spellings, defaults, errors, and codes. The golden
baseline diff is four hint lines gaining "Default: …". EventKitBridge.swift
goes from 544 to 475 lines.
Schema derives from Swift types at runtime so shared and nested result
structs compose; a @ToolResult macro generates the encoder and the schema
from one declaration; the declarations render a real Promise type. JSON a
script receives is unchanged.
Every built-in tool wrote a `codeModeTitle`, 120 of them, and no model ever
saw one: `JavaScriptAPIReference` carries no title, and neither search nor
the TypeScript declarations use it. The descriptor's title is now the last
path segment, as the host-facing macro already does, and the protocol no
longer asks for one. The golden baseline changes by exactly those 120 lines.
…, and move the logic into call

Three changes to the EventKit family that set the template for the
remaining bridge families.

**One tool per helper.** `createEvent` also answered to `updateEvent`, and
`createReminder` to `updateReminder` and `completeReminder`, through
aliases on one tool. Hand-written JavaScript in the bootstrap injected an
`operation` argument (and `isCompleted: true` for complete) before the call
reached Swift, so scripts behaved, but the catalog and the declarations
could not tell the helpers apart: `updateEvent` showed `identifier` as
optional, and a failed `updateEvent` got `createEvent`'s repair
suggestions. They are now five tools with their own `Arguments` (create
requires title/start/end as `Date`; update requires `identifier`; complete
requires `identifier` with `isCompleted` defaulting to true). The
hand-written bindings are deleted; the generated bootstrap is the only
binding for these names.

Several tools now share one `CapabilityID`, so the registry keeps a list per
ID, JavaScript dispatch passes the called helper name through to the
registry and to error enrichment (suggestions are looked up by name first),
and a host registration replaces every existing one whose names overlap, or
the whole ID when it names none. `byCapability` in search holds the first
helper; `byJSName` is exact.

**`Patch<Value>`.** Scripts could never clear a field: the registry rejected
`null` for typed arguments before any handler ran, so the "null clears"
branches, and the presence tracking added for them, were unreachable. A
`Patch` property is `.unchanged` when the key is missing, `.clear` on
`null`, `.set` otherwise; it is advertised as nullable through the
descriptor, `JavaScriptAPIReference` (decoded with a default) and the
declaration (`notes?: string | null`), and the registry accepts `null` only
there. Fields that cannot be cleared (title, start, end, an event's
calendar) stay plain optionals, so the declaration is honest. For an
`Arguments` struct with any `Patch` field the macro generates
`hasChanges(_:)`, so an update with nothing to change is rejected without a
hand-kept field list that would drift.

**Logic in `call`.** Each tool's body moved from `EventKitBridge` into the
tool; the bridge is now a namespace of the genuinely shared helpers
(calendar lookup, completion, serializers). The loader no longer holds an
instance. EventKitBridge.swift: 544 lines on main, 111 now.

Also: hints say only what the type cannot (the macro adds "ISO 8601
timestamp." for `Date` arguments; dynamic defaults read "… when omitted"),
pinned by a test; every catalog example is parsed against the live catalog;
`RemindersCreateTool.isCompleted` is honoured instead of ignored; a
capability-specific branch in the shared date parser is gone, so write-date
errors use the standard repairable message.
Every helper was declared `Promise<CodeModeValue>`, with a hand-written
`resultSummary` sentence (120 of them, 111 distinct) that nothing checked
against the JSON the bridge built. Models guessed field names from prose,
and a host's `tsc` could not catch a wrong one.

A result is now a `@ToolResult` struct. The macro generates its encoder and
its schema from the one declaration, so they cannot disagree; an optional
`@ToolField("hint")` becomes the field's doc comment. The schema
(`CodeModeResultSchema`) is derived from Swift types at runtime rather than
read by the macro, so shared and nested results compose: `CalendarEvent`
serves list, create and update, and `[CalendarEvent]` describes itself.
Its JSON form is hand-written and compact (`"string"`, `{"array": …}`,
`{"object": [{name, schema, hint?}]}`), since it is public, persisted in
the golden and eval fixtures, and shown to models in search results.

`BuiltInCodeModeTool.call` returns `Result: CodeModeResultDescribable`.
`JSONValue` conforms with schema `.any`, and `codeModeResultSummary` is a
protocol-extension default derived from the schema, so the 14 unmigrated
families compile unchanged with their prose overrides. The declarations
render the schema as the promise's type argument and drop the `Returns:`
line when it would only repeat the type.

EventKit is migrated: `CalendarEvent`, `Reminder` and a delete result replace
the hand-built serializers; the nine summary overrides are gone. The JSON a
script receives is unchanged, including `""` where a missing string is
coalesced today; whether those should become `null` is recorded in TODO.md
as a deliberate decision still to take, along with element types for
argument arrays and named interfaces in the whole-surface declarations.
@zac
zac merged commit 37f8baa into main Oct 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant