Skip to content
2 changes: 1 addition & 1 deletion PHASE3-HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Allowed golden diffs:
for spellings the bridge demonstrably accepts.
- Use it as the property type; the macro derives the constraint from it.
- Rewire the bridge's own parsing of that value to
`EnumType.codeModeValue(matching:)` (see `EventKitBridge.eventSpan`,
`EnumType.codeModeValue(matching:)` (see
`SystemUIBridge.validateCalendarPickerArguments` for the pattern), so the
enum is the single source of truth.
- Delete the row from the `defaults(for:)` table in the same commit.
Expand Down
92 changes: 92 additions & 0 deletions PLAN-async-bridges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Plan: asynchronous native calls

Status: decided, not started. Starts after `typed-bridges` merges, on its own branch.

## Problem

JS helpers already return promises. `__invokeAsync` (RuntimeJavaScript.swift) is
`Promise.resolve().then(syncInvoke)`. Inside that `.then`, though, the native call blocks the
execution thread until the Swift handler returns. Built-in tools that wrap async Apple APIs each
hand-roll a semaphore, a `Task`, a result box and a fixed timeout. There are about 15 of these
(WeatherBridge.swift is typical), plus `CompletionWait` in EventKit and the permission broker.
Host `@CodeMode` providers go through `CodeModeAsyncBridge.run`, which applies a fixed 30 s
timeout. The consequences:

- Native waits ignore the execution's own deadline: timeouts range from 10 to 30 s, per bridge.
- `cancel()` cannot interrupt a native wait.
- `Promise.all` over several helpers runs them one after another.

## Decision

Go truly asynchronous, not "async handlers behind one synchronous wait point". Helpers already
return promises, so this changes timing, not the surface scripts see. The synchronous-wait
design is not a stepping stone. Its wait point, and its assumption that no native work survives
a JS turn, would both be thrown away; only the async handler signature carries over, and the
first phase below delivers that anyway.

The first phase ships the asynchronous machinery with a concurrency width of 1. Delivery stays
sequential, and nothing observable changes except deadlines and cancellation. Turning on overlap
is a separate, deliberate change.

Reviewed by GPT-6 Astra (read-only) and decided by Fable. The two disagreed on these points, and
here is what was settled:

- **Outstanding native calls at settlement are drained, not cancelled.** The drain is bounded by
the remaining deadline and emits a diagnostic. Today an un-awaited
`apple.notifications.schedule(...)` still runs. Cancelling it would turn the most common
model mistake, a forgotten `await`, into silent data loss. Cancel only on timeout or host
cancel.
- **One built-in seam.** Built-in tools all go through `CapabilityRegistration.init(tool:)`, so
a single blocking-work adapter there covers every unmigrated tool. Only the public
`CapabilityHandler` needs both a sync and an async initializer.
- **Permission prompts are serialized per execution and per permission** with a lock in
`BridgeInvocationContext`. There are no shared in-flight futures; the broker's own request
already blocks.

## Phases

**1. Runtime-owned native waits (sequential delivery).**

What becomes true: every native operation is bounded by
`min(execution deadline, per-operation cap)`. `cancel()` interrupts a native wait immediately.
Late completions are dropped. `Promise.all` ordering is unchanged.

- `BridgeInvocationContext` carries an immutable deadline taken from the injected clock, the
remaining budget, and task registration so that cancel reaches registered work.
- `CapabilityHandler` gets an async form. Sync handlers and unmigrated tool bodies run on a
bounded GCD queue, bridged by a continuation. They never block inside a `Task` on the
cooperative pool, which can deadlock it.
- `BuiltInCodeModeTool.call` becomes `async throws`. The protocol is internal, so this is not a
host-facing break. The `@CodeMode` macro awaits `call` directly, and `CodeModeAsyncBridge.run`
is deprecated.
- In JS, `__bridgeStart(id, …)` returns immediately and keeps a pending map; delivery resolves
promises by id.
- In the runtime, an execution mailbox (a locked queue plus the condition `cancel()` already
broadcasts). `waitForSettlement` drains completions on the execution thread before checking
timers. It waits until the next completion, timer, deadline or cancel. It reports
"can never settle" only when no timer and no native call are pending.
- Admission width comes from `ExecutionLimits`, default 1.
- Rewrite the ExecutionWatchdog header comment and the `waitForSettlement` doc, which both state
that no native work is outstanding.
- Tests use `VirtualClock`: a deadline bounds a slow handler, cancel during a native wait, a late
completion is dropped, an un-awaited call still completes, `Promise.all` ordering is unchanged.

**2. Overlap.**

- Default width 4 per execution, plus a runtime-wide cap.
- A per-permission lock.
- An exclusive, runtime-wide lane for capabilities that present UI or prompt for permission.
- Size the blocking-work queue to executions × width.
- Update the docs that tell models native calls are sequential: README, `CodeModeAgentGuidance`,
`CodeModeAgentToolDescriptions`, and their tests.

**3+. One PR per bridge family.**

- Rewrite Weather, Health, Media, Network (async `URLSession`), Notifications, Alarm, Home and
Photos as native async bodies that honour task cancellation. Delete each family's semaphores
as it goes.
- Pair each family's rewrite with its typed-arguments migration, since the bodies are being
rewritten anyway.
- Then add async variants of `PermissionBroker` and `SystemUIPresenter`, with adapters from the
sync ones.
- Last, the service-client protocols in AppleServiceAdapters.swift.
88 changes: 88 additions & 0 deletions PLAN-typed-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Plan: typed results for built-in tools

Status: decided. Implemented on `typed-bridges` for the EventKit family first, then with each
family's typed-arguments migration.

## Problem

Every helper is declared `(…): Promise<CodeModeValue>`. The only description of what comes back
is `codeModeResultSummary`, a hand-written sentence (120 of them, 111 distinct) that nothing
checks against the JSON the bridge actually builds. Models guess field names from prose, a
host's `tsc` cannot catch a wrong one, and the summary drifts from the code the first time
someone renames a key.

## Shapes in use today

From the 120 summaries and the bridges behind them:

- an object with fixed fields, some present only "when available" (the common case);
- an array of such objects (every list helper);
- a string-keyed map of values (Vision's per-feature sections, form values keyed by field id);
- a nullable object (`keychain.get` returns null for a missing key);
- a handful of genuine unions (`location.read`: a status string or a coordinates object).

Unions stay `CodeModeValue` with prose. Everything else gets a schema.

## Design

**Schema is derived from Swift types at runtime, not parsed by the macro.** The macro is
syntactic and cannot see a struct declared elsewhere, but results are shared across tools
(`CalendarEvent` is returned by list, create and update) and nested (an array of events). So:

```swift
indirect enum CodeModeResultSchema: Sendable, Codable, Equatable {
case string, number, bool, any
case nullable(CodeModeResultSchema)
case array(CodeModeResultSchema)
case dictionary(CodeModeResultSchema)
case object([Field])
struct Field: Sendable, Codable, Equatable { var name: String; var schema: CodeModeResultSchema; var hint: String? }
}

protocol CodeModeResultDescribable: CodeModeJSONEncodable {
static var codeModeResultSchema: CodeModeResultSchema { get }
}
```

Conformances: `String`, `Int`, `Double`, `Float`, `Bool`, `Date` (an ISO 8601 string), `JSONValue`
(`.any`), `Optional` (`.nullable`), `Array`, `Dictionary<String, _>`. Composition is by the type
system, so `[CalendarEvent]?` describes itself.

**`@ToolResult` marks a result struct.** It generates `codeModeJSONValue` (an object of the
encoded stored properties, in declaration order) and `codeModeResultSchema` (an `.object` of
fields). An optional `@ToolField("hint")` on a property becomes the field's doc comment. Field
types must themselves be `CodeModeResultDescribable`; the compiler enforces it. One struct can
serve several tools.

**The tool protocol gains a typed result.**

```swift
protocol BuiltInCodeModeTool {
associatedtype Result: CodeModeResultDescribable
func call(arguments: Arguments, context: BridgeInvocationContext) throws -> Result
}
```

The registration handler encodes `result.codeModeJSONValue`. The descriptor carries
`Result.codeModeResultSchema` and a summary derived from it. `codeModeResultSummary` becomes a
protocol extension default rendered from the schema; a tool still returning `JSONValue`
(schema `.any`) overrides it with its existing prose. That is the migration seam: unmigrated
tools compile unchanged, apart from the derived default they already override.

**The catalog and the public surface.** `CapabilityDescriptor` and `JavaScriptAPIReference` gain
`resultSchema: CodeModeResultSchema?`, decoded with a default so earlier encodings still load.
`TypeScriptDeclarations` renders it as the promise's type argument: an object literal type with
`?` for nullable fields, `Array<…>`, `{ [key: string]: … }`, `| null`. The derived summary is a
one-line rendering of the same type, for search results.

**Behaviour preservation.** The JSON a script receives does not change: same keys, same values,
same `""` where the bridge coalesces a missing string today. Whether those should become `null`
(and therefore `string | null` in the declaration) is a separate, model-facing decision; it is
deferred, and the schema makes it a one-line change per field when taken.

## What this is not

- Not a change to the host-facing `@CodeMode` macro, which already has a `Result` struct. Aligning
the two macros on one implementation is desirable and later.
- Not result validation at runtime. The encoder is generated from the same struct the schema
comes from, so they cannot disagree.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ Platforms: iOS 18+, macOS 15+, visionOS 2+. watchOS is out of scope because the
## Installation

```swift
.package(url: "https://github.com/velos/CodeMode.swift", branch: "main")
.package(url: "https://github.com/velos/CodeMode.swift", from: "0.2.2")
```

No release is tagged yet; once `0.1.0` is, prefer `from: "0.1.0"`. Add the product to your target:
`main` may be ahead of the latest tag; depend on `branch: "main"` for unreleased changes. Add the product to your target:

```swift
.product(name: "CodeMode", package: "CodeMode")
Expand Down
17 changes: 12 additions & 5 deletions Sources/CodeMode/API/BridgeModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable {
public var requiredArguments: [String]
public var optionalArguments: [String]
public var argumentTypes: [String: CapabilityArgumentType]
public var nullableArguments: [String]
public var argumentHints: [String: String]
public var argumentConstraints: CapabilityArgumentConstraints
public var resultSummary: String
public var resultSchema: CodeModeResultSchema?
/// TypeScript declaration for this helper, generated from the fields above.
/// Return it from `searchJavaScriptAPI` to give the model real types rather
/// than a coarse `argumentTypes` map plus prose.
Expand All @@ -119,9 +121,11 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable {
requiredArguments: [String],
optionalArguments: [String],
argumentTypes: [String: CapabilityArgumentType],
nullableArguments: [String] = [],
argumentHints: [String: String],
argumentConstraints: CapabilityArgumentConstraints = .none,
resultSummary: String,
resultSchema: CodeModeResultSchema? = nil,
dts: String = ""
) {
self.capability = capability
Expand All @@ -134,22 +138,23 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable {
self.requiredArguments = requiredArguments
self.optionalArguments = optionalArguments
self.argumentTypes = argumentTypes
self.nullableArguments = nullableArguments
self.argumentHints = argumentHints
self.argumentConstraints = argumentConstraints
self.resultSummary = resultSummary
self.resultSchema = resultSchema
self.dts = dts
}

private enum CodingKeys: String, CodingKey {
case capability, capabilityKey, builtInCapability, jsNames, summary, tags, example
case requiredArguments, optionalArguments, argumentTypes, argumentHints
case argumentConstraints, resultSummary, dts
case nullableArguments, argumentConstraints, resultSummary, resultSchema, dts
}

/// `dts` is optional on the way in. References encoded before it existed —
/// cached search results, eval fixtures — would otherwise fail to decode with
/// `keyNotFound`; when it is absent, the declaration is regenerated from the
/// decoded fields rather than left empty.
/// Older cached references may omit `resultSchema` and `dts`. A missing
/// schema preserves the JSON fallback; a missing declaration is regenerated
/// from the decoded fields rather than left empty.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.capability = try container.decode(String.self, forKey: .capability)
Expand All @@ -162,9 +167,11 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable {
self.requiredArguments = try container.decode([String].self, forKey: .requiredArguments)
self.optionalArguments = try container.decode([String].self, forKey: .optionalArguments)
self.argumentTypes = try container.decode([String: CapabilityArgumentType].self, forKey: .argumentTypes)
self.nullableArguments = try container.decodeIfPresent([String].self, forKey: .nullableArguments) ?? []
self.argumentHints = try container.decode([String: String].self, forKey: .argumentHints)
self.argumentConstraints = try container.decode(CapabilityArgumentConstraints.self, forKey: .argumentConstraints)
self.resultSummary = try container.decode(String.self, forKey: .resultSummary)
self.resultSchema = try container.decodeIfPresent(CodeModeResultSchema.self, forKey: .resultSchema)
self.dts = try container.decodeIfPresent(String.self, forKey: .dts) ?? ""
if self.dts.isEmpty {
self.dts = TypeScriptDeclarations.declaration(for: self)
Expand Down
50 changes: 50 additions & 0 deletions Sources/CodeMode/API/CodeModeProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,56 @@ public protocol CodeModeJSONEncodable {
var codeModeJSONValue: JSONValue { get }
}

/// A result whose JSON encoder and model-facing shape come from the same type.
public protocol CodeModeResultDescribable: CodeModeJSONEncodable {
static var codeModeResultSchema: CodeModeResultSchema { get }
}

extension String: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .string }
}

extension Int: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .number }
}

extension Double: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .number }
}

extension Float: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .number }
}

extension Bool: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .bool }
}

extension Date: CodeModeJSONEncodable, CodeModeResultDescribable {
public var codeModeJSONValue: JSONValue { .string(ISO8601Format()) }
public static var codeModeResultSchema: CodeModeResultSchema { .string }
}

extension JSONValue: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .any }
}

extension Optional: CodeModeJSONEncodable where Wrapped: CodeModeJSONEncodable {
public var codeModeJSONValue: JSONValue { map(\.codeModeJSONValue) ?? .null }
}

extension Optional: CodeModeResultDescribable where Wrapped: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .nullable(Wrapped.codeModeResultSchema) }
}

extension Array: CodeModeResultDescribable where Element: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .array(Element.codeModeResultSchema) }
}

extension Dictionary: CodeModeResultDescribable where Key == String, Value: CodeModeResultDescribable {
public static var codeModeResultSchema: CodeModeResultSchema { .dictionary(Value.codeModeResultSchema) }
}

extension JSONValue: CodeModeJSONEncodable {
public var codeModeJSONValue: JSONValue { self }
}
Expand Down
27 changes: 23 additions & 4 deletions Sources/CodeMode/Bridges/BuiltInCodeModeMacros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@ import Foundation

/// Generates the mechanical members of a `BuiltInCodeModeTool` — the identity
/// statics, `codeModeArguments`, and `decode(arguments:)` — from the nested
/// `Arguments` struct. Curated metadata (title, summary, tags, example,
/// permissions, result summary) and `call(arguments:context:)` stay
/// `Arguments` struct. Curated metadata (summary, tags, example,
/// permissions, optional result summary override) and `call(arguments:context:)` stay
/// hand-written.
///
/// Annotate each argument property with `@ToolParam("hint")`. Property types
/// may be JSON primitives (`String`, `Bool`, `Int`, `Double`, `[JSONValue]`,
/// `[String: JSONValue]`, `JSONValue`, optionals thereof) or a
/// `CodeModeStringEnum`, whose accepted values become the argument's
/// constraint metadata. A trailing un-annotated `var raw: [String: JSONValue]`
/// constraint metadata. `Date`/`Date?` decode ISO 8601 strings through
/// `CodeModeDate`, retaining the capability and argument in date errors.
/// Nonoptional scalar properties may have literal defaults (or `.case` enum
/// defaults): omission uses the initializer, and catalog hints advertise it.
/// Dynamic defaults belong in `call` and use optional properties.
///
/// `Patch<T>` arguments distinguish omission, null, and a decoded value. Tools
/// with Patch fields get `static hasChanges(_:)`, which checks their Patch and
/// optional fields and skips required and defaulted fields. A
/// trailing unannotated `var raw: [String: JSONValue]`
/// receives the full argument dictionary with constrained values canonicalized
/// — the passthrough transitional tools use while bridges consume raw
/// dictionaries.
@attached(member, names: named(codeModeCapability), named(codeModePath), named(codeModeAliasPaths), named(codeModeArguments), named(decode))
@attached(member, names: named(codeModeCapability), named(codeModePath), named(codeModeAliasPaths), named(codeModeArguments), named(decode), named(hasChanges))
macro BuiltInCodeMode(
_ capability: CapabilityID,
path: String,
Expand All @@ -41,3 +50,13 @@ enum CodeModeToolRawArguments {
raw[name] = .string(value.rawValue)
}
}

/// Derives a built-in result's JSON encoding and schema from its stored var/let
/// properties, in declaration order. Each field type must describe its result.
@attached(member, names: named(codeModeJSONValue), named(codeModeResultSchema))
@attached(extension, conformances: CodeModeResultDescribable)
macro ToolResult() = #externalMacro(module: "CodeModeMacros", type: "ToolResultMacro")

/// Optional documentation for one stored property of a `@ToolResult` struct.
@attached(peer)
macro ToolField(_ hint: String) = #externalMacro(module: "CodeModeMacros", type: "ToolFieldMacro")
Loading
Loading