broapp 0.1.0 → 0.3.0

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 (39) hide show
  1. package/README.md +13 -4
  2. package/package.json +10 -2
  3. package/src/ai/host/adapter.ts +109 -0
  4. package/src/ai/host/create-ai.ts +266 -0
  5. package/src/ai/host/fake.ts +230 -0
  6. package/src/ai/host/from-contract.ts +105 -0
  7. package/src/ai/host/index.ts +37 -0
  8. package/src/ai/host/registry.ts +232 -0
  9. package/src/ai/host/run-types.ts +14 -0
  10. package/src/ai/host/run.ts +540 -0
  11. package/src/ai/host/secrets.ts +118 -0
  12. package/src/ai/host/settings.ts +83 -0
  13. package/src/ai/host/threads.ts +366 -0
  14. package/src/ai/host/tool.ts +95 -0
  15. package/src/ai/react/AiChat.tsx +242 -0
  16. package/src/ai/react/AiSettings.tsx +228 -0
  17. package/src/ai/react/ai.css +166 -0
  18. package/src/ai/react/index.tsx +38 -0
  19. package/src/ai/react/provider.tsx +97 -0
  20. package/src/ai/react/use-ai-chat.ts +317 -0
  21. package/src/ai/react/use-ai-models.ts +70 -0
  22. package/src/ai/react/use-ai-settings.ts +105 -0
  23. package/src/ai/shared/contract.ts +247 -0
  24. package/src/ai/shared/index.ts +19 -0
  25. package/src/ai/shared/types.check.ts +71 -0
  26. package/src/ai/shared/types.ts +147 -0
  27. package/src/host/app.ts +183 -36
  28. package/src/host/approvals.ts +115 -0
  29. package/src/host/gate.ts +380 -0
  30. package/src/host/index.ts +25 -0
  31. package/src/host/paths.ts +6 -2
  32. package/src/host/runtime.ts +23 -2
  33. package/src/react/hooks.tsx +37 -3
  34. package/src/react/index.ts +1 -0
  35. package/src/shared/contract.ts +99 -2
  36. package/src/shared/countdown.ts +36 -0
  37. package/src/shared/errors.ts +66 -2
  38. package/src/shared/index.ts +14 -3
  39. package/src/shared/schema.ts +141 -28
@@ -17,12 +17,28 @@
17
17
  */
18
18
  import type { Infer, Schema } from './schema.ts';
19
19
 
20
+ /**
21
+ * What an operation or stream does to the world.
22
+ *
23
+ * `read` changes nothing. `write` changes data inside the application's data
24
+ * directory. `external` reaches outside it: the network, other files, a
25
+ * spawned process, mail. The gate decides from this and from who is asking
26
+ * whether a call runs, waits for a person, or is refused. A route that does
27
+ * not say is treated as `write`, which asks a person before an agent may
28
+ * run it and lets the owner's own click through.
29
+ */
30
+ export type Effect = 'read' | 'write' | 'external';
31
+
32
+ /** The three strings an `effect` may be, for validation at definition time. */
33
+ const EFFECTS: readonly Effect[] = ['read', 'write', 'external'];
34
+
20
35
  /** One unary operation: JSON in, JSON out. */
21
36
  export interface OperationSpec<I = unknown, O = unknown> {
22
37
  readonly input: Schema<I>;
23
38
  readonly output: Schema<O>;
24
39
  /** Shown in generated documentation and in the developer panel. */
25
40
  readonly summary?: string;
41
+ readonly effect?: Effect;
26
42
  }
27
43
 
28
44
  /**
@@ -37,6 +53,12 @@ export interface StreamSpec<P = unknown, E = unknown> {
37
53
  readonly params: Schema<P>;
38
54
  readonly event: Schema<E>;
39
55
  readonly summary?: string;
56
+ readonly effect?: Effect;
57
+ }
58
+
59
+ /** The effect a route declares, or the conservative default. */
60
+ export function effectOf(spec: { readonly effect?: Effect }): Effect {
61
+ return spec.effect ?? 'write';
40
62
  }
41
63
 
42
64
  /** The operation and stream tables an application declares. */
@@ -94,8 +116,9 @@ export type StreamEvent<C extends AnyContract, K extends StreamName<C>> = Infer<
94
116
 
95
117
  /**
96
118
  * A route name is `group.member`. Brobridge resolves a unary call by splitting
97
- * on the first `.` and looking the group up in its service registry, so both
98
- * halves must be present and neither may itself contain a dot.
119
+ * on the *last* `.` and looking the group up in its service registry, and it
120
+ * refuses to expose a service whose name contains a dot — so both halves must
121
+ * be present and neither may itself contain one.
99
122
  */
100
123
  const ROUTE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
101
124
 
@@ -122,6 +145,20 @@ export function defineContract<const C extends ContractShape>(shape: C): Contrac
122
145
  );
123
146
  }
124
147
  }
148
+ // An `effect` that is not one of the three words would silently become the
149
+ // conservative default at the gate, which reads as a working declaration
150
+ // while meaning nothing. A typo is refused where it was written instead.
151
+ for (const [route, spec] of [
152
+ ...Object.entries(shape.operations),
153
+ ...Object.entries(shape.streams),
154
+ ] as readonly (readonly [string, { readonly effect?: unknown }])[]) {
155
+ const effect = spec.effect;
156
+ if (effect !== undefined && !(EFFECTS as readonly unknown[]).includes(effect)) {
157
+ throw new TypeError(
158
+ `route ${JSON.stringify(route)} declares effect ${JSON.stringify(effect)}, which must be "read", "write" or "external"`,
159
+ );
160
+ }
161
+ }
125
162
  const clash = operations.find((route) => streams.includes(route));
126
163
  if (clash !== undefined) {
127
164
  throw new TypeError(`route ${JSON.stringify(clash)} is declared as both an operation and a stream`);
@@ -132,3 +169,63 @@ export function defineContract<const C extends ContractShape>(shape: C): Contrac
132
169
  routes: { operations, streams },
133
170
  };
134
171
  }
172
+
173
+ /**
174
+ * Combine two contracts into one. Used in the browser so one client can
175
+ * speak an application's contract and Broapp's AI contract over one
176
+ * connection. Throws if any route name appears in both.
177
+ */
178
+ export function mergeContracts<A extends AnyContract, B extends AnyContract>(
179
+ a: A,
180
+ b: B,
181
+ ): Contract<{
182
+ operations: ShapeOf<A>['operations'] & ShapeOf<B>['operations'];
183
+ streams: ShapeOf<A>['streams'] & ShapeOf<B>['streams'];
184
+ }> {
185
+ // A clash is checked across all four tables, not table by table: a name that
186
+ // is an operation on one side and a stream on the other is just as
187
+ // unresolvable as a duplicate operation, because Brobridge dispatches on the
188
+ // route name alone.
189
+ const names = new Set<string>([...a.routes.operations, ...a.routes.streams]);
190
+ for (const route of [...b.routes.operations, ...b.routes.streams]) {
191
+ if (names.has(route)) throw new TypeError(`route ${JSON.stringify(route)} is declared by both contracts`);
192
+ }
193
+ const operations = { ...a.operations, ...b.operations };
194
+ const streams = { ...a.streams, ...b.streams };
195
+ return {
196
+ operations,
197
+ streams,
198
+ routes: { operations: Object.keys(operations), streams: Object.keys(streams) },
199
+ } as Contract<{
200
+ operations: ShapeOf<A>['operations'] & ShapeOf<B>['operations'];
201
+ streams: ShapeOf<A>['streams'] & ShapeOf<B>['streams'];
202
+ }>;
203
+ }
204
+
205
+ /**
206
+ * The route groups Broapp reserves for itself.
207
+ *
208
+ * `ai` belongs to the AI layer and `autoapp` to Autoapp's own host routes.
209
+ * Both are mounted as a second host app on the same bridge as the
210
+ * application's, so a name that appeared in both route tables would be
211
+ * unresolvable.
212
+ */
213
+ export const RESERVED_GROUPS: readonly string[] = ['ai', 'autoapp'];
214
+
215
+ /**
216
+ * Throws if a contract declares a route in a reserved group.
217
+ *
218
+ * This is not checked in `defineContract`, because Broapp's own contracts are
219
+ * built with `defineContract` and have to be allowed their groups. It is
220
+ * checked where an *application* contract enters the host instead.
221
+ */
222
+ export function assertNoReservedRoutes(contract: AnyContract): void {
223
+ for (const route of [...contract.routes.operations, ...contract.routes.streams]) {
224
+ const { group } = splitRoute(route);
225
+ if (RESERVED_GROUPS.includes(group)) {
226
+ throw new TypeError(
227
+ `route ${JSON.stringify(route)} uses the group ${JSON.stringify(group)}, which is reserved for Broapp`,
228
+ );
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How long a person has left to answer a question.
3
+ *
4
+ * Shared rather than written twice because the same countdown appears wherever
5
+ * an approval is shown — an approvals strip in an application's tab, a confirm
6
+ * card in a chat panel — and two formatters would eventually disagree about
7
+ * what "one minute left" looks like. It is pure arithmetic over a timestamp:
8
+ * nothing here decides anything, and the gate's own timer is what actually
9
+ * refuses a question nobody answered.
10
+ */
11
+
12
+ /** Below this, a question is close enough to running out to say so loudly. */
13
+ export const URGENT_MS = 60_000;
14
+
15
+ /** Milliseconds until `expiresAt`, never negative. */
16
+ export function remainingMs(expiresAt: number, now: number = Date.now()): number {
17
+ return Math.max(0, expiresAt - now);
18
+ }
19
+
20
+ /**
21
+ * The time left as `m:ss`.
22
+ *
23
+ * Rounded up, so a question with 600 ms left reads `0:01` rather than `0:00`:
24
+ * a countdown that says zero while the button still works is a countdown
25
+ * people stop believing.
26
+ */
27
+ export function countdown(expiresAt: number, now: number = Date.now()): string {
28
+ const seconds = Math.ceil(remainingMs(expiresAt, now) / 1000);
29
+ return `${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`;
30
+ }
31
+
32
+ /** True while a question is nearly out of time, and false once it is out. */
33
+ export function isUrgent(expiresAt: number, now: number = Date.now()): boolean {
34
+ const left = remainingMs(expiresAt, now);
35
+ return left > 0 && left < URGENT_MS;
36
+ }
@@ -80,6 +80,60 @@ export class PublicError extends Error {
80
80
  }
81
81
  }
82
82
 
83
+ /**
84
+ * True when `error` is a {@link PublicError}, including one from another copy
85
+ * of this module.
86
+ *
87
+ * `instanceof` is not enough, and the reason is architectural rather than
88
+ * pedantic. An Autoapp release bundles its own copy of `broapp`, while the
89
+ * child runtime that supervises it has the copy compiled into the launcher —
90
+ * so a `PublicError` thrown by the gate in one and caught by `runOperation` in
91
+ * the other is a different class object with the same shape. Reducing it to
92
+ * "internal error" would silently turn every deliberate refusal, the preview
93
+ * policy's included, into a mystery.
94
+ *
95
+ * The check is deliberately narrow: the name, and a `code` from the known set.
96
+ * Nothing an untrusted value could set by accident.
97
+ */
98
+ export function isPublicError(error: unknown): error is PublicError {
99
+ // No `instanceof PublicError` fast path. It would be true for one of the two
100
+ // copies and false for the other, and a check whose answer depends on which
101
+ // bundle asked is the bug this function exists to fix — including in the
102
+ // reader's mind, the next time somebody copies this shape.
103
+ if (!(error instanceof Error) || error.name !== 'PublicError') return false;
104
+ const code: unknown = (error as { code?: unknown }).code;
105
+ return typeof code === 'string' && (PUBLIC_CODES as readonly string[]).includes(code);
106
+ }
107
+
108
+ /**
109
+ * True when `error` is a {@link BroappError}, including one from another copy
110
+ * of this module.
111
+ *
112
+ * Same reason as {@link isPublicError}. `fromTransportError` is shared code:
113
+ * the browser calls it inside one bundle, and the Autoapp child runtime calls
114
+ * it on an error that came out of a release's own bundle.
115
+ */
116
+ function isBroappError(error: unknown): error is BroappError {
117
+ if (!(error instanceof Error) || error.name !== 'BroappError') return false;
118
+ const code: unknown = (error as { code?: unknown }).code;
119
+ return typeof code === 'string' && (PUBLIC_CODES as readonly string[]).includes(code);
120
+ }
121
+
122
+ /**
123
+ * True when `error` is Brobridge's cancellation, from any copy of Brobridge.
124
+ *
125
+ * A release bundles its own, so the class identity is not shared. `ErrorCode`
126
+ * is a set of string constants rather than a symbol, which is what makes the
127
+ * value comparison meaningful across copies.
128
+ */
129
+ function isCancelled(error: unknown): boolean {
130
+ return (
131
+ error instanceof Error &&
132
+ error.name === 'BridgeError' &&
133
+ (error as { code?: unknown }).code === ErrorCode.CANCELLED
134
+ );
135
+ }
136
+
83
137
  /** The message every unhandled host failure becomes, on both sides. */
84
138
  export const INTERNAL_ERROR_MESSAGE = 'The application could not complete that operation.';
85
139
 
@@ -105,7 +159,7 @@ export class BroappError extends Error {
105
159
  * for a user to read.
106
160
  */
107
161
  export function fromTransportError(error: unknown): BroappError {
108
- if (error instanceof BroappError) return error;
162
+ if (isBroappError(error)) return error;
109
163
  const message = error instanceof Error ? error.message : '';
110
164
  if (message.startsWith(MARKER)) {
111
165
  const space = message.indexOf(' ');
@@ -118,12 +172,22 @@ export function fromTransportError(error: unknown): BroappError {
118
172
  );
119
173
  }
120
174
  }
121
- if (error instanceof BridgeError && error.code === ErrorCode.CANCELLED) {
175
+ if (isCancelled(error)) {
122
176
  return new BroappError('rejected', 'The operation was cancelled.', error);
123
177
  }
124
178
  return new BroappError('internal', INTERNAL_ERROR_MESSAGE, error);
125
179
  }
126
180
 
181
+ /**
182
+ * True when an error already carries a message written for the browser.
183
+ *
184
+ * Anything else is a host-side failure whose message may name a path, a query
185
+ * or a token, and must be reduced before it leaves the host.
186
+ */
187
+ export function isPublicBridgeError(error: unknown): boolean {
188
+ return error instanceof Error && error.message.startsWith(MARKER);
189
+ }
190
+
127
191
  /** Convenience constructors, so a handler reads as prose. */
128
192
  export const publicError = {
129
193
  invalidInput: (message: string): PublicError => new PublicError('invalid_input', message),
@@ -5,11 +5,19 @@
5
5
  * description and the types derived from it. That is what makes it safe for
6
6
  * the browser bundle to follow.
7
7
  */
8
- export { defineContract, splitRoute } from './contract.ts';
8
+ export {
9
+ assertNoReservedRoutes,
10
+ defineContract,
11
+ effectOf,
12
+ mergeContracts,
13
+ RESERVED_GROUPS,
14
+ splitRoute,
15
+ } from './contract.ts';
9
16
  export type {
10
17
  AnyContract,
11
18
  Contract,
12
19
  ContractShape,
20
+ Effect,
13
21
  ShapeOf,
14
22
  OperationInput,
15
23
  OperationName,
@@ -21,16 +29,19 @@ export type {
21
29
  StreamSpec,
22
30
  } from './contract.ts';
23
31
 
24
- export { s, ValidationError } from './schema.ts';
25
- export type { Infer, Issue, Result, Schema } from './schema.ts';
32
+ export { isValidationError, s, ValidationError } from './schema.ts';
33
+ export type { Infer, InferObject, Issue, JsonSchema, Result, Schema } from './schema.ts';
26
34
 
27
35
  export {
28
36
  BroappError,
29
37
  INTERNAL_ERROR_MESSAGE,
30
38
  PublicError,
31
39
  fromTransportError,
40
+ isPublicError,
32
41
  publicError,
33
42
  } from './errors.ts';
34
43
  export type { PublicErrorCode } from './errors.ts';
35
44
 
36
45
  export { encodeEvent, MAX_EVENT_BYTES, NdjsonDecoder } from './ndjson.ts';
46
+
47
+ export { countdown, isUrgent, remainingMs, URGENT_MS } from './countdown.ts';
@@ -35,6 +35,22 @@ export class ValidationError extends Error {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * True when `error` is a {@link ValidationError}, including one from another
40
+ * copy of this module.
41
+ *
42
+ * `instanceof` is not enough for the same reason it is not enough for
43
+ * `PublicError`: an Autoapp release bundles its own copy of `broapp`, so a
44
+ * schema built in one bundle and parsed by code in another throws a different
45
+ * class object with the same shape. Reducing that to "invalid input" with no
46
+ * message would tell a caller nothing about what was actually wrong.
47
+ */
48
+ export function isValidationError(error: unknown): error is ValidationError {
49
+ return (
50
+ error instanceof Error && error.name === 'ValidationError' && Array.isArray((error as { issues?: unknown }).issues)
51
+ );
52
+ }
53
+
38
54
  function formatPath(path: IssuePath): string {
39
55
  let out = '';
40
56
  for (const segment of path) {
@@ -49,6 +65,15 @@ export type Result<T> =
49
65
  | { readonly ok: true; readonly value: T }
50
66
  | { readonly ok: false; readonly issues: readonly Issue[] };
51
67
 
68
+ /**
69
+ * A JSON Schema document, as a plain object.
70
+ *
71
+ * Broapp emits a draft 2020-12 subset — enough for a language model provider
72
+ * to describe a tool's arguments, and nothing more. It is deliberately not a
73
+ * typed tree: every consumer so far hands it straight to a provider as JSON.
74
+ */
75
+ export type JsonSchema = Record<string, unknown>;
76
+
52
77
  /** A runtime schema for one JSON value. */
53
78
  export interface Schema<T> {
54
79
  /** Discriminates a Broapp schema from a foreign one at runtime. */
@@ -57,6 +82,8 @@ export interface Schema<T> {
57
82
  check(value: unknown, path?: IssuePath): Result<T>;
58
83
  /** Validate, or throw {@link ValidationError}. */
59
84
  parse(value: unknown): T;
85
+ /** A JSON Schema (draft 2020-12 subset) describing what `parse` accepts. */
86
+ toJsonSchema(): JsonSchema;
60
87
  /** Phantom marker; never present at runtime. */
61
88
  readonly _type?: T;
62
89
  }
@@ -64,7 +91,28 @@ export interface Schema<T> {
64
91
  /** The TypeScript type a schema accepts. */
65
92
  export type Infer<S> = S extends Schema<infer T> ? T : never;
66
93
 
67
- function schema<T>(kind: string, check: (value: unknown, path: IssuePath) => Result<T>): Schema<T> {
94
+ /** Collapse an intersection back into one object type, keeping `?` modifiers. */
95
+ type Flatten<T> = { [K in keyof T]: T[K] };
96
+
97
+ /**
98
+ * What an object schema accepts.
99
+ *
100
+ * A field wrapped in `s.optional` may be left out entirely, so its key is
101
+ * optional here rather than merely admitting `undefined`. Without this an
102
+ * `s.optional` field would still have to be spelled out at every construction
103
+ * site, which is the opposite of what the wrapper says.
104
+ */
105
+ export type InferObject<F> = Flatten<
106
+ { [K in keyof F as undefined extends Infer<F[K]> ? never : K]: Infer<F[K]> } & {
107
+ [K in keyof F as undefined extends Infer<F[K]> ? K : never]?: Infer<F[K]>;
108
+ }
109
+ >;
110
+
111
+ function schema<T>(
112
+ kind: string,
113
+ check: (value: unknown, path: IssuePath) => Result<T>,
114
+ toJsonSchema: () => JsonSchema,
115
+ ): Schema<T> {
68
116
  const self: Schema<T> = {
69
117
  kind,
70
118
  check: (value, path = []) => check(value, path),
@@ -73,10 +121,25 @@ function schema<T>(kind: string, check: (value: unknown, path: IssuePath) => Res
73
121
  if (outcome.ok) return outcome.value;
74
122
  throw new ValidationError(outcome.issues);
75
123
  },
124
+ toJsonSchema,
76
125
  };
77
126
  return self;
78
127
  }
79
128
 
129
+ /**
130
+ * Assemble a JSON Schema object, dropping every keyword whose option was not
131
+ * given. An explicit `minLength: undefined` disappears from `JSON.stringify`
132
+ * but is still a key at runtime, and a provider that enumerates keywords would
133
+ * see it — so the key is never created in the first place.
134
+ */
135
+ function keywords(entries: Record<string, unknown>): JsonSchema {
136
+ const out: JsonSchema = {};
137
+ for (const [key, value] of Object.entries(entries)) {
138
+ if (value !== undefined) out[key] = value;
139
+ }
140
+ return out;
141
+ }
142
+
80
143
  function fail<T = never>(path: IssuePath, message: string): Result<T> {
81
144
  return { ok: false, issues: [{ path, message }] };
82
145
  }
@@ -118,7 +181,15 @@ export const s = {
118
181
  if (!anchored.test(value)) return fail(path, 'does not match the required format');
119
182
  }
120
183
  return { ok: true, value };
121
- });
184
+ },
185
+ () =>
186
+ keywords({
187
+ type: 'string',
188
+ minLength: options.min,
189
+ maxLength: options.max,
190
+ pattern: options.pattern?.source,
191
+ }),
192
+ );
122
193
  },
123
194
 
124
195
  number(options: NumberOptions = {}): Schema<number> {
@@ -134,30 +205,46 @@ export const s = {
134
205
  return fail(path, `expected <= ${String(options.max)}`);
135
206
  }
136
207
  return { ok: true, value };
137
- });
208
+ },
209
+ () =>
210
+ keywords({
211
+ type: options.int === true ? 'integer' : 'number',
212
+ minimum: options.min,
213
+ maximum: options.max,
214
+ }),
215
+ );
138
216
  },
139
217
 
140
218
  boolean(): Schema<boolean> {
141
- return schema('boolean', (value, path) =>
142
- typeof value === 'boolean' ? { ok: true, value } : fail(path, 'expected a boolean'),
219
+ return schema(
220
+ 'boolean',
221
+ (value, path) =>
222
+ typeof value === 'boolean' ? { ok: true, value } : fail(path, 'expected a boolean'),
223
+ () => ({ type: 'boolean' }),
143
224
  );
144
225
  },
145
226
 
146
227
  literal<const T extends string | number | boolean>(expected: T): Schema<T> {
147
- return schema('literal', (value, path) =>
148
- value === expected
149
- ? { ok: true, value: expected }
150
- : fail(path, `expected ${JSON.stringify(expected)}`),
228
+ return schema(
229
+ 'literal',
230
+ (value, path) =>
231
+ value === expected
232
+ ? { ok: true, value: expected }
233
+ : fail(path, `expected ${JSON.stringify(expected)}`),
234
+ () => ({ const: expected }),
151
235
  );
152
236
  },
153
237
 
154
238
  /** A closed set of string values. */
155
239
  enum<const T extends readonly string[]>(values: T): Schema<T[number]> {
156
240
  const allowed = new Set<string>(values);
157
- return schema('enum', (value, path) =>
158
- typeof value === 'string' && allowed.has(value)
159
- ? { ok: true, value: value as T[number] }
160
- : fail(path, `expected one of ${values.map((v) => JSON.stringify(v)).join(', ')}`),
241
+ return schema(
242
+ 'enum',
243
+ (value, path) =>
244
+ typeof value === 'string' && allowed.has(value)
245
+ ? { ok: true, value: value as T[number] }
246
+ : fail(path, `expected one of ${values.map((v) => JSON.stringify(v)).join(', ')}`),
247
+ () => ({ type: 'string', enum: [...values] }),
161
248
  );
162
249
  },
163
250
 
@@ -178,7 +265,15 @@ export const s = {
178
265
  else issues.push(...outcome.issues);
179
266
  }
180
267
  return issues.length > 0 ? { ok: false, issues } : { ok: true, value: out };
181
- });
268
+ },
269
+ () =>
270
+ keywords({
271
+ type: 'array',
272
+ items: item.toJsonSchema(),
273
+ minItems: options.min,
274
+ maxItems: options.max,
275
+ }),
276
+ );
182
277
  },
183
278
 
184
279
  /**
@@ -188,9 +283,7 @@ export const s = {
188
283
  * handler contains only what the schema named, so a property smuggled in by
189
284
  * a caller cannot reach application code by accident.
190
285
  */
191
- object<F extends Record<string, Schema<unknown>>>(
192
- fields: F,
193
- ): Schema<{ [K in keyof F]: Infer<F[K]> }> {
286
+ object<F extends Record<string, Schema<unknown>>>(fields: F): Schema<InferObject<F>> {
194
287
  const entries = Object.entries(fields);
195
288
  return schema('object', (value, path) => {
196
289
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -208,30 +301,50 @@ export const s = {
208
301
  }
209
302
  return issues.length > 0
210
303
  ? { ok: false, issues }
211
- : { ok: true, value: out as { [K in keyof F]: Infer<F[K]> } };
212
- });
304
+ : { ok: true, value: out as InferObject<F> };
305
+ },
306
+ () => ({
307
+ type: 'object',
308
+ properties: Object.fromEntries(entries.map(([key, field]) => [key, field.toJsonSchema()])),
309
+ // An optional field is one the object may leave out, so optionality is
310
+ // expressed here rather than inside the field's own schema.
311
+ required: entries.filter(([, field]) => field.kind !== 'optional').map(([key]) => key),
312
+ additionalProperties: false,
313
+ }),
314
+ );
213
315
  },
214
316
 
215
317
  /** A value that may be absent or `undefined`. */
216
318
  optional<T>(inner: Schema<T>): Schema<T | undefined> {
217
- return schema<T | undefined>('optional', (value, path) =>
218
- value === undefined ? { ok: true, value: undefined } : inner.check(value, path),
319
+ return schema<T | undefined>(
320
+ 'optional',
321
+ (value, path) => (value === undefined ? { ok: true, value: undefined } : inner.check(value, path)),
322
+ // JSON Schema has no "optional" keyword; the enclosing object omits the
323
+ // key from `required` instead.
324
+ () => inner.toJsonSchema(),
219
325
  );
220
326
  },
221
327
 
222
328
  /** A value that may be `null`. */
223
329
  nullable<T>(inner: Schema<T>): Schema<T | null> {
224
- return schema<T | null>('nullable', (value, path) =>
225
- value === null ? { ok: true, value: null } : inner.check(value, path),
330
+ return schema<T | null>(
331
+ 'nullable',
332
+ (value, path) => (value === null ? { ok: true, value: null } : inner.check(value, path)),
333
+ () => ({ anyOf: [inner.toJsonSchema(), { type: 'null' }] }),
226
334
  );
227
335
  },
228
336
 
229
337
  /** Nothing at all. The input type of an operation that takes no argument. */
230
338
  void(): Schema<void> {
231
- return schema('void', (value, path) =>
232
- value === undefined || value === null
233
- ? { ok: true, value: undefined }
234
- : fail(path, 'expected no value'),
339
+ return schema(
340
+ 'void',
341
+ (value, path) =>
342
+ value === undefined || value === null
343
+ ? { ok: true, value: undefined }
344
+ : fail(path, 'expected no value'),
345
+ // A provider that asks for a tool's arguments wants an object, and an
346
+ // operation that takes nothing takes an empty one.
347
+ () => ({ type: 'object', properties: {}, additionalProperties: false }),
235
348
  );
236
349
  },
237
350
 
@@ -243,6 +356,6 @@ export const s = {
243
356
  * data is untrusted.
244
357
  */
245
358
  unknown(): Schema<unknown> {
246
- return schema('unknown', (value) => ({ ok: true, value }));
359
+ return schema('unknown', (value) => ({ ok: true, value }), () => ({}));
247
360
  },
248
361
  } as const;