@opengeni/runtime 3.0.2-canary.38894290271001 → 3.1.2

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.
@@ -7,8 +7,7 @@ import {
7
7
  } from "@opengeni/contracts";
8
8
  import { boundComputerNativeReceipt } from "@opengeni/interaction";
9
9
  import {
10
- AttachedBrowserBridge,
11
- AttachedBrowserDevice,
10
+ AttachedBrowserDeviceListResponse,
12
11
  AuthRun,
13
12
  AuthRunListResponse,
14
13
  AuthRunMutationResponse,
@@ -29,6 +28,13 @@ import {
29
28
  BrowserIdentityListResponse,
30
29
  BrowserIdentityMutationResponse,
31
30
  BrowserObservation,
31
+ BrowserActionResolvedTarget,
32
+ BrowserDialog,
33
+ InteractionDiagnosticSummary,
34
+ InteractionError,
35
+ InteractionOperationState,
36
+ InteractionRect,
37
+ RetainedArtifactReferenceSchema,
32
38
  InteractionSemanticNode,
33
39
  BrowserRevisionListResponse,
34
40
  BrowserSession,
@@ -119,31 +125,70 @@ const BrowserAgentNode = z
119
125
  z.object({ redacted: z.literal(true), reason: z.string().max(32) }).strict(),
120
126
  ])
121
127
  .optional(),
122
- states: z.array(z.string().max(BROWSER_AGENT_MAX_FIELD_LENGTH)).max(6),
123
- actions: z.array(z.string().max(BROWSER_AGENT_MAX_FIELD_LENGTH)).max(6),
128
+ // Omitted when empty: most nodes have no states or actions, and repeating
129
+ // empty arrays on every node only costs the model tokens.
130
+ states: z.array(z.string().max(BROWSER_AGENT_MAX_FIELD_LENGTH)).min(1).max(6).optional(),
131
+ actions: z.array(z.string().max(BROWSER_AGENT_MAX_FIELD_LENGTH)).min(1).max(6).optional(),
124
132
  })
125
133
  .strict();
134
+ /** The model-facing view keeps only what a next call can use: what is on the
135
+ * page, the refs to act on, the fences an action may pass, and explicit budget
136
+ * omissions. Zero counts and constant limits are left out. */
126
137
  const BrowserAgentView = z
127
138
  .object({
128
- kind: z.literal("compact"),
129
139
  sourceKind: z.enum(["snapshot", "diff", "none"]),
130
140
  nodes: z.array(BrowserAgentNode).max(BROWSER_AGENT_MAX_NODES),
131
- sourceNodeCount: z.number().int().nonnegative(),
132
- omittedNodeCount: z.number().int().nonnegative(),
133
- removedRefCount: z.number().int().nonnegative(),
134
- clippedFieldCount: z.number().int().nonnegative(),
135
- maxNodes: z.number().int().positive(),
136
- maxNodeBytes: z.number().int().positive(),
141
+ omittedNodeCount: z.number().int().positive().optional(),
142
+ removedRefCount: z.number().int().positive().optional(),
143
+ clippedFieldCount: z.number().int().positive().optional(),
144
+ })
145
+ .strict();
146
+ /** A tab as the model needs it: identity, page, selection and the generations
147
+ * an action or human request may pass as fences. Timestamps stay out. */
148
+ const AgentBrowserTarget = z
149
+ .object({
150
+ id: z.string().max(512),
151
+ kind: z.enum(["page", "popup", "background_page", "worker"]),
152
+ title: z.string().max(4_096),
153
+ url: z.string().max(16_384),
154
+ selected: z.boolean(),
155
+ controllerGeneration: z.string().max(256),
156
+ targetGeneration: z.string().max(256),
157
+ documentGeneration: z.string().max(256).nullable(),
158
+ })
159
+ .strict();
160
+ const CompactBrowserObservation = z
161
+ .object({
162
+ target: AgentBrowserTarget,
163
+ frameId: z.string().max(256).optional(),
164
+ viewport: z
165
+ .object({
166
+ width: z.number().int().positive(),
167
+ height: z.number().int().positive(),
168
+ deviceScaleFactor: z.number().positive().optional(),
169
+ maxTouchPoints: z.number().int().positive().optional(),
170
+ })
171
+ .strict()
172
+ .optional(),
173
+ focusedRef: z.string().max(512).optional(),
174
+ changedRegions: z.array(InteractionRect).min(1).optional(),
175
+ diagnostics: InteractionDiagnosticSummary.optional(),
176
+ dialog: BrowserDialog.optional(),
177
+ screenshot: RetainedArtifactReferenceSchema.optional(),
178
+ agentView: BrowserAgentView,
137
179
  })
138
180
  .strict();
139
- const CompactBrowserObservation = BrowserObservation.safeExtend({
140
- semantic: z.null(),
141
- agentView: BrowserAgentView,
142
- });
143
181
  const BrowserAgentObservation = z.union([BrowserObservation, CompactBrowserObservation]);
144
- const BrowserAgentActionReceipt = BrowserActionReceipt.safeExtend({
145
- observation: BrowserAgentObservation.nullable(),
146
- });
182
+ const CompactBrowserActionReceipt = z
183
+ .object({
184
+ targetId: z.string().max(512),
185
+ state: InteractionOperationState,
186
+ error: InteractionError.optional(),
187
+ targets: z.array(BrowserActionResolvedTarget).optional(),
188
+ observation: CompactBrowserObservation.optional(),
189
+ })
190
+ .strict();
191
+ const BrowserAgentActionReceipt = z.union([BrowserActionReceipt, CompactBrowserActionReceipt]);
147
192
  const BrowserScreenshotInput = z
148
193
  .object({
149
194
  browserSessionId: z.string().uuid(),
@@ -283,6 +328,7 @@ type FlatBrowserNode = {
283
328
  depth: number;
284
329
  index: number;
285
330
  withinScope: boolean;
331
+ parentName?: string;
286
332
  };
287
333
 
288
334
  const TOOL_PERMISSION = {
@@ -318,6 +364,28 @@ const DiscoveryInput = z
318
364
  includeDisconnectedDevices: z.boolean().optional(),
319
365
  })
320
366
  .strict();
367
+ /** An attached Chrome profile as the model needs it to choose one: identity,
368
+ * labels, state and any capability it lacks. Enrollment, generation and
369
+ * timestamp bookkeeping stay out. */
370
+ const AgentAttachedBrowser = z
371
+ .object({
372
+ id: z.string().uuid(),
373
+ name: z.string().max(200),
374
+ profileLabel: z.string().max(200).optional(),
375
+ browserName: z.string().max(100),
376
+ platform: z.enum(["linux", "macos", "windows"]),
377
+ state: z.enum(["connected", "disconnected"]),
378
+ tabCount: z.number().int().nonnegative(),
379
+ unsupported: z.array(z.string().max(64)).min(1).optional(),
380
+ })
381
+ .strict();
382
+ /** An online machine that can host the extension but has no attached profile. */
383
+ const AgentAttachedBrowserBridge = z
384
+ .object({
385
+ enrollmentId: z.string().uuid(),
386
+ connectedProfileCount: z.number().int().nonnegative(),
387
+ })
388
+ .strict();
321
389
  const DiscoveryOutput = z
322
390
  .object({
323
391
  browserRevision: z.number().int().nonnegative(),
@@ -327,8 +395,23 @@ const DiscoveryOutput = z
327
395
  browsers: z.array(BrowserSession),
328
396
  computers: z.array(ComputerSession),
329
397
  identities: z.array(BrowserIdentity),
330
- attachedBrowserBridges: z.array(AttachedBrowserBridge),
331
- attachedBrowsers: z.array(AttachedBrowserDevice),
398
+ attachedBrowserBridges: z.array(AgentAttachedBrowserBridge),
399
+ attachedBrowsers: z.array(AgentAttachedBrowser),
400
+ })
401
+ .strict();
402
+
403
+ /** browser_open may name the person's connected Chrome without a profile id;
404
+ * the tool then resolves it (see chooseAttachedProfile). */
405
+ const BrowserOpenPlacement = z.union([
406
+ InteractionPlacement,
407
+ z.object({ kind: z.literal("attached_device") }).strict(),
408
+ ]);
409
+ /** The Chrome profile browser_open chose when the request named none. */
410
+ const AgentAttachedProfile = z
411
+ .object({
412
+ id: z.string().uuid(),
413
+ name: z.string(),
414
+ profileLabel: z.string().optional(),
332
415
  })
333
416
  .strict();
334
417
 
@@ -340,7 +423,7 @@ const BrowserOpenInput = z
340
423
  initialUrl: z.string().url().max(16_384).optional(),
341
424
  headless: z.boolean().optional(),
342
425
  storageMode: BrowserStorageMode.optional(),
343
- placement: InteractionPlacement.optional(),
426
+ placement: BrowserOpenPlacement.optional(),
344
427
  identityId: z.string().uuid().optional(),
345
428
  baseRevisionId: z.string().uuid().optional(),
346
429
  networkRouteId: z.string().uuid().optional(),
@@ -384,7 +467,14 @@ const BrowserOpenInput = z
384
467
  }
385
468
  });
386
469
  const BrowserOpenOutput = z
387
- .object({ session: BrowserSession, targets: z.array(BrowserTarget) })
470
+ .object({
471
+ session: BrowserSession,
472
+ targets: z.array(AgentBrowserTarget),
473
+ otherTabCount: z.number().int().nonnegative().optional(),
474
+ observation: CompactBrowserObservation.optional(),
475
+ attachedProfile: AgentAttachedProfile.optional(),
476
+ otherAttachedProfiles: z.array(AgentAttachedProfile).optional(),
477
+ })
388
478
  .strict();
389
479
 
390
480
  const BrowserTabsInput = z.discriminatedUnion("operation", [
@@ -411,9 +501,13 @@ const BrowserTabsInput = z.discriminatedUnion("operation", [
411
501
  })
412
502
  .strict(),
413
503
  ]);
414
- const BrowserTabsOutput = BrowserTargetListResponse.safeExtend({
415
- mutationObservation: CompactBrowserObservation.optional(),
416
- });
504
+ const BrowserTabsOutput = z
505
+ .object({
506
+ browserSessionId: z.string().uuid(),
507
+ targets: z.array(AgentBrowserTarget),
508
+ mutationObservation: CompactBrowserObservation.optional(),
509
+ })
510
+ .strict();
417
511
 
418
512
  const BrowserObserveInput = z
419
513
  .object({
@@ -722,7 +816,7 @@ export function createInteractionAttemptToolDefinitions(
722
816
  name: "interaction_discover",
723
817
  codemodePath: ["interaction", "discover"],
724
818
  title: "Discover browsers and computers",
725
- description: `Discover browsers and computers. For the user's existing/current/personal Chrome, use scope=attached_browsers before browser_open and select an actual attachedBrowsers device. This reads only Chrome profiles and extension bridges, avoiding unrelated sessions and saved identities. The default current_session scope lists this agent session's BrowserSessions and ComputerSessions. Use scope=workspace only for reusable identities or peer/child resources; that inventory can be large. An attachedBrowserBridge only means the machine is ready for the extension; only attachedBrowsers are real user Chrome profiles/tabs. If none exist, the person needs a Connected Machine first (post the Connected Machine card with capability_authorization_request api:connected-machine) and then the Opengeni Browser Chrome extension: ${OPENGENI_BROWSER_EXTENSION_URL}. Leave includeTerminal=false unless ended history is specifically required.`,
819
+ description: `Discover browsers and computers. To use the user's existing/current/personal Chrome, call browser_open with placement={kind:'attached_device'} directly; use scope=attached_browsers only to pick a specific Chrome profile or to see why none is connected. That scope reads only Chrome profiles and extension bridges, avoiding unrelated sessions and saved identities. The default current_session scope lists this agent session's BrowserSessions and ComputerSessions. Use scope=workspace only for reusable identities or peer/child resources; that inventory can be large. An attachedBrowserBridge only means the machine is ready for the extension; only attachedBrowsers are real user Chrome profiles/tabs. If none exist, the person needs a Connected Machine first (post the Connected Machine card with capability_authorization_request api:connected-machine) and then the Opengeni Browser Chrome extension: ${OPENGENI_BROWSER_EXTENSION_URL}. Leave includeTerminal=false unless ended history is specifically required.`,
726
820
  input: DiscoveryInput,
727
821
  output: DiscoveryOutput,
728
822
  readOnly: true,
@@ -743,8 +837,7 @@ export function createInteractionAttemptToolDefinitions(
743
837
  browsers: [],
744
838
  computers: [],
745
839
  identities: [],
746
- attachedBrowserBridges: attached.bridges,
747
- attachedBrowsers: attached.devices,
840
+ ...agentAttachedInventory(attached),
748
841
  };
749
842
  }
750
843
  // Narrow in SQL: a workspace can retain thousands of ended sessions, and
@@ -781,8 +874,9 @@ export function createInteractionAttemptToolDefinitions(
781
874
  value.includeTerminal ?? false,
782
875
  ),
783
876
  identities: scope === "workspace" ? identities.identities : [],
784
- attachedBrowserBridges: scope === "workspace" ? attached.bridges : [],
785
- attachedBrowsers: scope === "workspace" ? attached.devices : [],
877
+ ...(scope === "workspace"
878
+ ? agentAttachedInventory(attached)
879
+ : { attachedBrowserBridges: [], attachedBrowsers: [] }),
786
880
  };
787
881
  },
788
882
  });
@@ -791,7 +885,7 @@ export function createInteractionAttemptToolDefinitions(
791
885
  name: "browser_open",
792
886
  codemodePath: ["interaction", "browser", "open"],
793
887
  title: "Open or reuse browser",
794
- description: `Open a managed BrowserSession on the current agent placement, reuse a relevant compatible live session by default, attach to an explicit workspace BrowserSession, or open an attached Chrome profile by passing placement={kind:'attached_device',deviceId}. This does not infer or attach the user's existing Chrome: for requests about 'my browser', 'my tabs', or current Chrome, call interaction_discover first and select an actual attachedBrowsers device; if none exists, explain that the Chrome extension must be connected instead of silently creating a blank managed browser (it needs a Connected Machine, then the Opengeni Browser extension: ${OPENGENI_BROWSER_EXTENSION_URL}). BrowserSessions persist across tool calls; shell-launched browser daemons do not survive remote-command cleanup. Never switch to the user's attached Chrome as a fallback for a failed managed browser unless the user requested that profile. A new attached session opens a dedicated tab. Managed Chromium defaults to headed so OAuth and later human interaction use a supported browser; request headless=true only for agent-only work that will not require sign-in or human control. Operator-enabled disposable sandbox verification may explicitly request storageMode=ephemeral_context with headless=true: no saved identity, route, Computer, checkpoint or resume; process loss ends the session. Default private_profile storage is unchanged. Returns exact session and tab state.`,
888
+ description: `Open a managed BrowserSession on the current agent placement, reuse a relevant compatible live session by default, attach to an explicit workspace BrowserSession, or open the user's own Chrome with placement={kind:'attached_device'}. Use that directly for requests about 'my browser', 'my tabs', or current Chrome: it opens the only Chrome profile connected through the Opengeni Browser extension, or else the most recently active one (reported as attachedProfile, the rest as otherAttachedProfiles). Add deviceId (from interaction_discover scope=attached_browsers or otherAttachedProfiles) only when the user wants a different profile. Omitting placement never uses the user's Chrome. If no profile is connected, explain that the Chrome extension must be connected instead of silently creating a blank managed browser (it needs a Connected Machine, then the Opengeni Browser extension: ${OPENGENI_BROWSER_EXTENSION_URL}). BrowserSessions persist across tool calls; shell-launched browser daemons do not survive remote-command cleanup. Never switch to the user's attached Chrome as a fallback for a failed managed browser unless the user requested that profile. A new attached session opens a dedicated tab. Managed Chromium defaults to headed so OAuth and later human interaction use a supported browser; request headless=true only for agent-only work that will not require sign-in or human control. Operator-enabled disposable sandbox verification may explicitly request storageMode=ephemeral_context with headless=true: no saved identity, route, Computer, checkpoint or resume; process loss ends the session. Default private_profile storage is unchanged. Returns exact session and tab state, plus observation: a compact snapshot of the opened (or selected) tab when one is available. With initialUrl, targets holds only the opened tab and otherTabCount counts the rest (browser_tabs list shows them). Reuse its refs and generations for the first action instead of observing again.`,
795
889
  input: BrowserOpenInput,
796
890
  output: BrowserOpenOutput,
797
891
  readOnly: false,
@@ -825,10 +919,12 @@ export function createInteractionAttemptToolDefinitions(
825
919
  value.targetId,
826
920
  );
827
921
  } else if (value.operation === "close") {
828
- return await input.transport.closeBrowserTarget(
829
- input.workspaceId,
830
- value.browserSessionId,
831
- value.targetId,
922
+ return agentTargetList(
923
+ await input.transport.closeBrowserTarget(
924
+ input.workspaceId,
925
+ value.browserSessionId,
926
+ value.targetId,
927
+ ),
832
928
  );
833
929
  }
834
930
  const tabs = await input.transport.listBrowserTargets(
@@ -837,10 +933,10 @@ export function createInteractionAttemptToolDefinitions(
837
933
  );
838
934
  return mutationObservation
839
935
  ? {
840
- ...tabs,
936
+ ...agentTargetList(tabs),
841
937
  mutationObservation: projectBrowserObservation(mutationObservation, "compact"),
842
938
  }
843
- : tabs;
939
+ : agentTargetList(tabs);
844
940
  },
845
941
  });
846
942
 
@@ -849,7 +945,7 @@ export function createInteractionAttemptToolDefinitions(
849
945
  codemodePath: ["interaction", "browser", "observe"],
850
946
  title: "Observe browser tab",
851
947
  description:
852
- "Read one tab's URL, title, viewport, causal generations, and a bounded accessibility view. Compact view is the default: agentView lists selected refs and reports omitted nodes and clipped fields; it is not a complete page tree. Set view=full for the complete accessibility snapshot. Set includeScreenshot=true to add a current still image as tool image content; browser_screenshot is faster when only pixels are needed.",
948
+ "Read one tab's URL, title, viewport, causal generations, and a bounded accessibility view. Compact view is the default: agentView lists selected refs, and omittedNodeCount or clippedFieldCount appear only when something was left out; it is not a complete page tree. Empty and default fields are omitted. Set view=full for the complete accessibility snapshot. Set includeScreenshot=true to add a current still image as tool image content; browser_screenshot is faster when only pixels are needed.",
853
949
  input: BrowserObserveInput,
854
950
  output: BrowserAgentObservation,
855
951
  readOnly: true,
@@ -1022,7 +1118,7 @@ export function createInteractionAttemptToolDefinitions(
1022
1118
  codemodePath: ["interaction", "browser", "act"],
1023
1119
  title: "Act in browser tab",
1024
1120
  description:
1025
- "Perform one semantic-first browser action or bounded batch. Prefer action={type:'batch',actions:[...],fenceEachAction:true} for predictable edits in the same observed document, such as filling known form fields before one authorized submit. Pause for new page state, a dialog, or a decision instead of batching steps that depend on an unseen result. Reuse a page observation, when present, from tab open/select or a prior action; browser_open returns only session/tab metadata, so observe when page content is needed. Observe again after page changes or when needed content was omitted. If an action reports outcome_unknown, inspect the outcome and do not replay the batch. Use viewport to set page width, height, desktop/mobile layout and touch emulation, then check the measured viewport in the returned observation; emulation does not prove physical mobile-browser behavior. Use history back/forward for tab navigation; keypress shortcuts are page input and may not navigate browser history. A press key is one character or a key name (Enter, Tab, Escape, Backspace, Delete, Insert, Home, End, PageUp, PageDown, ArrowUp/Down/Left/Right, Space, F1-F24), optionally prefixed by Ctrl+, Alt+, Shift+, Meta+ or Mod+. The explicit activate action foregrounds the target in the user's desktop browser; use only when that is intended. Permission actions set a managed browser's web permission for this tab's exact current top-level origin. Omit generation fences to fetch fresh generation metadata without scanning accessibility; supplying all three fences skips that state read while preserving controller validation. The default receipt includes a compact accessibility view with explicit omissions; set view=full for the complete observation, or view=none when no post-action tree is needed. An upload action takes workspace File IDs; the artifact.artifactId returned by sandbox_file_publish is one. Text in fill, type and clipboard write may contain {{secret:…}} references; the value (or the current TOTP code for /otp) is typed at dispatch and never shown to you. Use type with mode:'keys' for split one-time-code boxes or inputs that ignore pasted text.",
1121
+ "Perform one semantic-first browser action or bounded batch. Prefer action={type:'batch',actions:[...],fenceEachAction:true} for predictable edits in the same observed document, such as filling known form fields before one authorized submit. Pause for new page state, a dialog, or a decision instead of batching steps that depend on an unseen result. Reuse a page observation, when present, from browser_open, tab open/select or a prior action; observe only when none is present or it lacks needed content. Observe again after page changes or when needed content was omitted. If an action reports outcome_unknown, inspect the outcome and do not replay the batch. Use viewport to set page width, height, desktop/mobile layout and touch emulation, then check the measured viewport in the returned observation; emulation does not prove physical mobile-browser behavior. Use history back/forward for tab navigation; keypress shortcuts are page input and may not navigate browser history. A press key is one character or a key name (Enter, Tab, Escape, Backspace, Delete, Insert, Home, End, PageUp, PageDown, ArrowUp/Down/Left/Right, Space, F1-F24), optionally prefixed by Ctrl+, Alt+, Shift+, Meta+ or Mod+. The explicit activate action foregrounds the target in the user's desktop browser; use only when that is intended. Permission actions set a managed browser's web permission for this tab's exact current top-level origin. Omit generation fences to fetch fresh generation metadata without scanning accessibility; supplying all three fences skips that state read while preserving controller validation. The default receipt includes a compact accessibility view with explicit omissions; set view=full for the complete observation, or view=none when no post-action tree is needed. An upload action takes workspace File IDs; the artifact.artifactId returned by sandbox_file_publish is one. Text in fill, type and clipboard write may contain {{secret:…}} references; the value (or the current TOTP code for /otp) is typed at dispatch and never shown to you. Use type with mode:'keys' for split one-time-code boxes or inputs that ignore pasted text.",
1026
1122
  input: BrowserActInput,
1027
1123
  output: BrowserAgentActionReceipt,
1028
1124
  readOnly: false,
@@ -1049,9 +1145,7 @@ export function createInteractionAttemptToolDefinitions(
1049
1145
  action: value.action,
1050
1146
  },
1051
1147
  );
1052
- return receipt.observation && (value.view ?? "compact") === "compact"
1053
- ? { ...receipt, observation: projectBrowserObservation(receipt.observation, "compact") }
1054
- : receipt;
1148
+ return value.view === "full" ? receipt : compactActionReceipt(receipt);
1055
1149
  },
1056
1150
  });
1057
1151
 
@@ -1659,12 +1753,66 @@ export function createInteractionAttemptToolDefinitions(
1659
1753
  return definitions;
1660
1754
  }
1661
1755
 
1662
- /** Preserve exact platform alternatives instead of flattening away constraints. */
1663
- function nativeCuaSchema(variants: Record<string, unknown>[]): AttemptToolJsonSchema {
1756
+ /**
1757
+ * One top-level object schema for every platform variant of a native CUA tool.
1758
+ * Model providers require a plain object at the top level of a function schema
1759
+ * (a top-level anyOf is rejected or ignored, so the model loses every argument
1760
+ * type). Properties are the union across platforms; a property whose platform
1761
+ * schemas differ in more than wording keeps those alternatives at property
1762
+ * level. Only properties every platform requires stay required. The session's
1763
+ * CUA worker still validates arguments against its own exact platform schema.
1764
+ */
1765
+ export function nativeCuaSchema(variants: Record<string, unknown>[]): AttemptToolJsonSchema {
1664
1766
  const unique = [...new Map(variants.map((schema) => [JSON.stringify(schema), schema])).values()];
1665
- return (
1666
- unique.length === 1 ? unique[0] : { type: "object", anyOf: unique }
1667
- ) as AttemptToolJsonSchema;
1767
+ if (unique.length === 1) return unique[0] as AttemptToolJsonSchema;
1768
+ const properties: Record<string, unknown> = {};
1769
+ const names = [...new Set(unique.flatMap((schema) => Object.keys(objectProperties(schema))))];
1770
+ for (const name of names) {
1771
+ const alternatives = unique
1772
+ .map((schema) => objectProperties(schema)[name])
1773
+ .filter((value): value is Record<string, unknown> => isJsonObject(value));
1774
+ // The first platform's wording wins for constraint-identical schemas.
1775
+ const distinct = new Map<string, Record<string, unknown>>();
1776
+ for (const schema of alternatives) {
1777
+ const key = JSON.stringify(withoutWording(schema));
1778
+ if (!distinct.has(key)) distinct.set(key, schema);
1779
+ }
1780
+ const options = [...distinct.values()];
1781
+ properties[name] = options.length === 1 ? options[0] : { anyOf: options };
1782
+ }
1783
+ const required = names.filter((name) =>
1784
+ unique.every((schema) =>
1785
+ Array.isArray(schema.required) ? schema.required.includes(name) : false,
1786
+ ),
1787
+ );
1788
+ return {
1789
+ type: "object",
1790
+ properties,
1791
+ ...(required.length > 0 ? { required } : {}),
1792
+ ...(unique.every((schema) => schema.additionalProperties === false)
1793
+ ? { additionalProperties: false }
1794
+ : {}),
1795
+ } as AttemptToolJsonSchema;
1796
+ }
1797
+
1798
+ function objectProperties(schema: Record<string, unknown>): Record<string, unknown> {
1799
+ return isJsonObject(schema.properties) ? schema.properties : {};
1800
+ }
1801
+
1802
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
1803
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1804
+ }
1805
+
1806
+ /** Compare platform property schemas by constraint, not by explanatory text. */
1807
+ function withoutWording(value: unknown): unknown {
1808
+ if (Array.isArray(value)) return value.map(withoutWording);
1809
+ if (!isJsonObject(value)) return value;
1810
+ return Object.fromEntries(
1811
+ Object.entries(value)
1812
+ .filter(([key]) => key !== "description" && key !== "title" && key !== "default")
1813
+ .sort(([a], [b]) => a.localeCompare(b))
1814
+ .map(([key, entry]) => [key, withoutWording(entry)]),
1815
+ );
1668
1816
  }
1669
1817
 
1670
1818
  function filterDiscoveredSessions<
@@ -1764,18 +1912,28 @@ async function openBrowser(
1764
1912
  ): Promise<z.input<typeof BrowserOpenOutput>> {
1765
1913
  let session: z.infer<typeof BrowserSession>;
1766
1914
  let created = false;
1915
+ let profileChoice: AttachedProfileChoice | undefined;
1767
1916
  if (value.browserSessionId) {
1768
1917
  session = await transport.getBrowserSession(workspaceId, value.browserSessionId);
1769
1918
  if (value.storageMode && browserSessionStorageMode(session) !== value.storageMode)
1770
1919
  throw new Error("existing BrowserSession storage mode does not match request");
1771
1920
  } else {
1772
- const listed =
1921
+ // placement={kind:'attached_device'} without a deviceId means "the person's
1922
+ // own Chrome". Read the profile inventory alongside the reuse lookup so the
1923
+ // resolution adds no sequential round trip.
1924
+ const resolveProfile =
1925
+ value.placement?.kind === "attached_device" && !("deviceId" in value.placement);
1926
+ const [listed, attachedInventory] = await Promise.all([
1773
1927
  value.mode === "new"
1774
1928
  ? { sessions: [] }
1775
- : await transport.listBrowserSessions(workspaceId, {
1929
+ : transport.listBrowserSessions(workspaceId, {
1776
1930
  associatedSessionId: sourceSessionId,
1777
1931
  includeTerminal: false,
1778
- });
1932
+ }),
1933
+ resolveProfile
1934
+ ? transport.listAttachedBrowsers(workspaceId, { includeDisconnected: false })
1935
+ : null,
1936
+ ]);
1779
1937
  // The agent-facing browser is human-capable by default. A headless session
1780
1938
  // is a deliberately narrower execution mode and must never be silently
1781
1939
  // reused for an omitted/default headed request: OAuth providers such as
@@ -1804,6 +1962,11 @@ async function openBrowser(
1804
1962
  session = reusable;
1805
1963
  } else {
1806
1964
  created = true;
1965
+ if (attachedInventory) profileChoice = chooseAttachedProfile(attachedInventory);
1966
+ // Without a deviceId, chooseAttachedProfile has resolved one (or thrown).
1967
+ const placement = profileChoice
1968
+ ? { kind: "attached_device" as const, deviceId: profileChoice.chosen.id }
1969
+ : (value.placement as z.infer<typeof InteractionPlacement> | undefined);
1807
1970
  session = (
1808
1971
  await transport.createBrowserSession(workspaceId, {
1809
1972
  operationId: context.operationId,
@@ -1812,7 +1975,7 @@ async function openBrowser(
1812
1975
  ...(value.initialUrl ? { initialUrl: value.initialUrl } : {}),
1813
1976
  headless: requestedHeadless,
1814
1977
  ...(value.storageMode ? { storageMode: value.storageMode } : {}),
1815
- ...(value.placement ? { placement: value.placement } : {}),
1978
+ ...(placement ? { placement } : {}),
1816
1979
  ...(value.identityId ? { identityId: value.identityId } : {}),
1817
1980
  ...(value.baseRevisionId ? { baseRevisionId: value.baseRevisionId } : {}),
1818
1981
  ...(value.networkRouteId ? { networkRouteId: value.networkRouteId } : {}),
@@ -1830,7 +1993,15 @@ async function openBrowser(
1830
1993
  })
1831
1994
  ).session;
1832
1995
  }
1833
- if (session.lifecycle !== "active") return { session, targets: [] };
1996
+ const chosenProfile = profileChoice
1997
+ ? {
1998
+ attachedProfile: agentAttachedProfile(profileChoice.chosen),
1999
+ ...(profileChoice.others.length > 0
2000
+ ? { otherAttachedProfiles: profileChoice.others.map(agentAttachedProfile) }
2001
+ : {}),
2002
+ }
2003
+ : {};
2004
+ if (session.lifecycle !== "active") return { session, targets: [], ...chosenProfile };
1834
2005
  let targets = (await transport.listBrowserTargets(workspaceId, session.id)).targets;
1835
2006
  if (
1836
2007
  value.initialUrl &&
@@ -1859,7 +2030,101 @@ async function openBrowser(
1859
2030
  }
1860
2031
  targets = inventory.targets;
1861
2032
  }
1862
- return { session, targets };
2033
+ const primary = openedBrowserTarget(targets, value.initialUrl);
2034
+ const observation = primary
2035
+ ? await observeOpenedBrowserTarget(transport, workspaceId, session.id, primary)
2036
+ : undefined;
2037
+ // A browser opened on a URL is about that tab. A person's own Chrome profile
2038
+ // can hold dozens of unrelated tabs; listing them all costs context on every
2039
+ // later step, so only the opened tab is returned with a count of the rest
2040
+ // (browser_tabs list shows them).
2041
+ const opened =
2042
+ value.initialUrl && primary && sameBrowserUrl(primary.url, value.initialUrl)
2043
+ ? primary
2044
+ : undefined;
2045
+ const shown = opened && targets.length > 1 ? [opened] : targets;
2046
+ return {
2047
+ session,
2048
+ targets: shown.map(agentBrowserTarget),
2049
+ ...(shown.length < targets.length ? { otherTabCount: targets.length - shown.length } : {}),
2050
+ ...(observation ? { observation } : {}),
2051
+ ...chosenProfile,
2052
+ };
2053
+ }
2054
+
2055
+ type AttachedProfileChoice = {
2056
+ chosen: z.infer<typeof AttachedBrowserDeviceListResponse>["devices"][number];
2057
+ others: z.infer<typeof AttachedBrowserDeviceListResponse>["devices"];
2058
+ };
2059
+
2060
+ /** The person's own Chrome when browser_open names no profile: the only
2061
+ * connected profile, else the one whose tabs changed most recently (a device's
2062
+ * updatedAt moves with its tab inventory, not with heartbeats). */
2063
+ function chooseAttachedProfile(
2064
+ inventory: z.infer<typeof AttachedBrowserDeviceListResponse>,
2065
+ ): AttachedProfileChoice {
2066
+ const usable = inventory.devices
2067
+ .filter((device) => device.state === "connected" && device.capabilities.debuggerAttachment)
2068
+ .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
2069
+ const [chosen, ...others] = usable;
2070
+ if (!chosen) {
2071
+ throw new OpenGeniApiError(
2072
+ 409,
2073
+ `No Chrome profile is connected through the Opengeni Browser extension. The person needs a Connected Machine, then the extension: ${OPENGENI_BROWSER_EXTENSION_URL}`,
2074
+ { code: "attached_browser_unavailable", retryable: false },
2075
+ );
2076
+ }
2077
+ return { chosen, others };
2078
+ }
2079
+
2080
+ function agentAttachedProfile(
2081
+ device: AttachedProfileChoice["chosen"],
2082
+ ): z.infer<typeof AgentAttachedProfile> {
2083
+ return {
2084
+ id: device.id,
2085
+ name: device.name,
2086
+ ...(device.profileLabel && device.profileLabel !== device.name
2087
+ ? { profileLabel: device.profileLabel }
2088
+ : {}),
2089
+ };
2090
+ }
2091
+
2092
+ /**
2093
+ * The tab the agent most likely acts on next: the tab showing the requested
2094
+ * URL, else the selected page.
2095
+ */
2096
+ function openedBrowserTarget(
2097
+ targets: z.infer<typeof BrowserTarget>[],
2098
+ initialUrl: string | undefined,
2099
+ ): z.infer<typeof BrowserTarget> | undefined {
2100
+ const pages = targets.filter((target) => target.kind === "page");
2101
+ return (
2102
+ (initialUrl ? pages.find((target) => sameBrowserUrl(target.url, initialUrl)) : undefined) ??
2103
+ pages.find((target) => target.selected) ??
2104
+ (pages.length === 1 ? pages[0] : undefined)
2105
+ );
2106
+ }
2107
+
2108
+ /**
2109
+ * A compact observation of the opened tab. Opening a browser is almost always
2110
+ * followed by reading it, so this saves that model round trip. It is a
2111
+ * read-only convenience: if it fails, the open still succeeds and the agent can
2112
+ * observe explicitly.
2113
+ */
2114
+ async function observeOpenedBrowserTarget(
2115
+ transport: InteractionTransport,
2116
+ workspaceId: string,
2117
+ browserSessionId: string,
2118
+ primary: z.infer<typeof BrowserTarget>,
2119
+ ): Promise<z.infer<typeof CompactBrowserObservation> | undefined> {
2120
+ try {
2121
+ return projectBrowserObservation(
2122
+ await transport.observeBrowserTarget(workspaceId, browserSessionId, primary.id),
2123
+ "compact",
2124
+ );
2125
+ } catch {
2126
+ return undefined;
2127
+ }
1863
2128
  }
1864
2129
 
1865
2130
  function sameBrowserUrl(left: string, right: string): boolean {
@@ -1916,7 +2181,7 @@ async function openComputer(
1916
2181
 
1917
2182
  function compatibleInteractionPlacement(
1918
2183
  candidate: z.infer<typeof InteractionPlacement>,
1919
- requested: z.infer<typeof InteractionPlacement> | undefined,
2184
+ requested: z.infer<typeof BrowserOpenPlacement> | undefined,
1920
2185
  ): boolean {
1921
2186
  if (!requested) return candidate.kind !== "attached_device";
1922
2187
  return Object.entries(requested).every(
@@ -1972,7 +2237,12 @@ function flattenBrowserNodes(
1972
2237
  scopeRef?: string,
1973
2238
  ): FlatBrowserNode[] {
1974
2239
  const flattened: FlatBrowserNode[] = [];
1975
- const stack = [...roots].reverse().map((node) => ({ node, depth: 0, withinScope: !scopeRef }));
2240
+ const stack: Array<{
2241
+ node: BrowserSemanticNodeValue;
2242
+ depth: number;
2243
+ withinScope: boolean;
2244
+ parentName?: string;
2245
+ }> = [...roots].reverse().map((node) => ({ node, depth: 0, withinScope: !scopeRef }));
1976
2246
  while (stack.length > 0) {
1977
2247
  const next = stack.pop()!;
1978
2248
  const withinScope = next.withinScope || next.node.ref === scopeRef;
@@ -1981,14 +2251,53 @@ function flattenBrowserNodes(
1981
2251
  depth: next.depth,
1982
2252
  index: flattened.length,
1983
2253
  withinScope,
2254
+ ...(next.parentName !== undefined ? { parentName: next.parentName } : {}),
1984
2255
  });
1985
2256
  for (const child of [...(next.node.children ?? [])].reverse()) {
1986
- stack.push({ node: child, depth: next.depth + 1, withinScope });
2257
+ stack.push({
2258
+ node: child,
2259
+ depth: next.depth + 1,
2260
+ withinScope,
2261
+ ...(next.node.name !== undefined ? { parentName: next.node.name } : {}),
2262
+ });
1987
2263
  }
1988
2264
  }
1989
2265
  return flattened;
1990
2266
  }
1991
2267
 
2268
+ /** Container roles that help the model orient even without a name. */
2269
+ const BROWSER_AGENT_STRUCTURAL_ROLES = new Set([
2270
+ "dialog",
2271
+ "alertdialog",
2272
+ "alert",
2273
+ "status",
2274
+ "navigation",
2275
+ "main",
2276
+ "form",
2277
+ "table",
2278
+ "grid",
2279
+ "list",
2280
+ "listbox",
2281
+ "menu",
2282
+ "menubar",
2283
+ "tablist",
2284
+ "radiogroup",
2285
+ ]);
2286
+
2287
+ /** Nodes that add nothing to the compact view: static text repeating its
2288
+ * parent's accessible name, and unnamed, stateless, non-actionable containers.
2289
+ * Their children keep their own depth, so the outline stays readable. */
2290
+ function redundantAgentNode(flat: FlatBrowserNode): boolean {
2291
+ const { node } = flat;
2292
+ if (node.actions.length > 0 || node.states.length > 0) return false;
2293
+ const hasValue = node.value !== undefined && node.value !== null && node.value !== "";
2294
+ if (node.role === "text" || node.role === "StaticText") {
2295
+ return !hasValue && !node.description && (!node.name || node.name === flat.parentName);
2296
+ }
2297
+ if (BROWSER_AGENT_STRUCTURAL_ROLES.has(node.role)) return false;
2298
+ return !node.name && !node.description && !hasValue;
2299
+ }
2300
+
1992
2301
  function boundedAgentField(value: string, onClip: () => void): string {
1993
2302
  if (value.length <= BROWSER_AGENT_MAX_FIELD_LENGTH) return value;
1994
2303
  onClip();
@@ -2018,8 +2327,12 @@ function projectAgentNode(flat: FlatBrowserNode): {
2018
2327
  typeof source.value === "string" ? boundedAgentField(source.value, clip) : source.value,
2019
2328
  }
2020
2329
  : {}),
2021
- states: source.states.slice(0, 6).map((state) => boundedAgentField(state, clip)),
2022
- actions: source.actions.slice(0, 6).map((action) => boundedAgentField(action, clip)),
2330
+ ...(source.states.length > 0
2331
+ ? { states: source.states.slice(0, 6).map((state) => boundedAgentField(state, clip)) }
2332
+ : {}),
2333
+ ...(source.actions.length > 0
2334
+ ? { actions: source.actions.slice(0, 6).map((action) => boundedAgentField(action, clip)) }
2335
+ : {}),
2023
2336
  };
2024
2337
  if (source.states.length > 6) clippedFieldCount += source.states.length - 6;
2025
2338
  if (source.actions.length > 6) clippedFieldCount += source.actions.length - 6;
@@ -2082,29 +2395,126 @@ function projectBrowserObservation(
2082
2395
  : semantic?.kind === "diff"
2083
2396
  ? semantic.changed
2084
2397
  : [];
2085
- const flattened = flattenBrowserNodes(roots);
2398
+ const flattened = flattenBrowserNodes(roots).filter((flat) => !redundantAgentNode(flat));
2086
2399
  const projected = selectAgentNodes(
2087
2400
  flattened,
2088
2401
  BROWSER_AGENT_MAX_NODES,
2089
2402
  BROWSER_AGENT_MAX_NODE_BYTES,
2090
2403
  );
2404
+ const omittedNodeCount = flattened.length - projected.nodes.length;
2405
+ const removedRefCount = semantic?.kind === "diff" ? semantic.removedRefs.length : 0;
2406
+ const viewport = observation.viewport;
2407
+ const diagnostics = observation.diagnostics;
2408
+ const hasDiagnostics =
2409
+ diagnostics.consoleErrorCount > 0 ||
2410
+ diagnostics.failedRequestCount > 0 ||
2411
+ diagnostics.downloadCount > 0 ||
2412
+ diagnostics.pageErrorCount > 0;
2091
2413
  return {
2092
- ...observation,
2093
- semantic: null,
2414
+ target: agentBrowserTarget(observation.target),
2415
+ ...(observation.frameId ? { frameId: observation.frameId } : {}),
2416
+ ...(viewport
2417
+ ? {
2418
+ viewport: {
2419
+ width: viewport.width,
2420
+ height: viewport.height,
2421
+ ...(viewport.deviceScaleFactor !== 1
2422
+ ? { deviceScaleFactor: viewport.deviceScaleFactor }
2423
+ : {}),
2424
+ ...(viewport.maxTouchPoints > 0 ? { maxTouchPoints: viewport.maxTouchPoints } : {}),
2425
+ },
2426
+ }
2427
+ : {}),
2428
+ ...(observation.focusedRef ? { focusedRef: observation.focusedRef } : {}),
2429
+ ...(observation.changedRegions.length > 0
2430
+ ? { changedRegions: observation.changedRegions }
2431
+ : {}),
2432
+ ...(hasDiagnostics ? { diagnostics } : {}),
2433
+ ...(observation.dialog ? { dialog: observation.dialog } : {}),
2434
+ ...(observation.screenshot ? { screenshot: observation.screenshot } : {}),
2094
2435
  agentView: {
2095
- kind: "compact",
2096
2436
  sourceKind: semantic?.kind ?? "none",
2097
2437
  nodes: projected.nodes,
2098
- sourceNodeCount: flattened.length,
2099
- omittedNodeCount: flattened.length - projected.nodes.length,
2100
- removedRefCount: semantic?.kind === "diff" ? semantic.removedRefs.length : 0,
2101
- clippedFieldCount: projected.clippedFieldCount,
2102
- maxNodes: BROWSER_AGENT_MAX_NODES,
2103
- maxNodeBytes: BROWSER_AGENT_MAX_NODE_BYTES,
2438
+ ...(omittedNodeCount > 0 ? { omittedNodeCount } : {}),
2439
+ ...(removedRefCount > 0 ? { removedRefCount } : {}),
2440
+ ...(projected.clippedFieldCount > 0
2441
+ ? { clippedFieldCount: projected.clippedFieldCount }
2442
+ : {}),
2104
2443
  },
2105
2444
  };
2106
2445
  }
2107
2446
 
2447
+ function agentBrowserTarget(
2448
+ target: z.infer<typeof BrowserTarget>,
2449
+ ): z.infer<typeof AgentBrowserTarget> {
2450
+ return {
2451
+ id: target.id,
2452
+ kind: target.kind,
2453
+ title: target.title,
2454
+ url: target.url,
2455
+ selected: target.selected,
2456
+ controllerGeneration: target.controllerGeneration,
2457
+ targetGeneration: target.targetGeneration,
2458
+ documentGeneration: target.documentGeneration,
2459
+ };
2460
+ }
2461
+
2462
+ /** Attached profiles are what the model selects. Bridges only explain why
2463
+ * none is available, so they are listed (online only) when no profile is. */
2464
+ function agentAttachedInventory(inventory: z.infer<typeof AttachedBrowserDeviceListResponse>): {
2465
+ attachedBrowserBridges: z.infer<typeof AgentAttachedBrowserBridge>[];
2466
+ attachedBrowsers: z.infer<typeof AgentAttachedBrowser>[];
2467
+ } {
2468
+ const attachedBrowsers = inventory.devices.map((device) => {
2469
+ const unsupported = Object.entries(device.capabilities)
2470
+ .filter(([, supported]) => !supported)
2471
+ .map(([capability]) => capability);
2472
+ return {
2473
+ id: device.id,
2474
+ name: device.name,
2475
+ ...(device.profileLabel ? { profileLabel: device.profileLabel } : {}),
2476
+ browserName: device.browserName,
2477
+ platform: device.platform,
2478
+ state: device.state,
2479
+ tabCount: device.tabCount,
2480
+ ...(unsupported.length > 0 ? { unsupported } : {}),
2481
+ };
2482
+ });
2483
+ const anyConnected = inventory.devices.some((device) => device.state === "connected");
2484
+ const attachedBrowserBridges = anyConnected
2485
+ ? []
2486
+ : inventory.bridges
2487
+ .filter((bridge) => bridge.state === "online")
2488
+ .map((bridge) => ({
2489
+ enrollmentId: bridge.enrollmentId,
2490
+ connectedProfileCount: bridge.connectedProfileCount,
2491
+ }));
2492
+ return { attachedBrowserBridges, attachedBrowsers };
2493
+ }
2494
+
2495
+ function agentTargetList(
2496
+ list: z.infer<typeof BrowserTargetListResponse>,
2497
+ ): z.infer<typeof BrowserTabsOutput> {
2498
+ return {
2499
+ browserSessionId: list.browserSessionId,
2500
+ targets: list.targets.map(agentBrowserTarget),
2501
+ };
2502
+ }
2503
+
2504
+ function compactActionReceipt(
2505
+ receipt: z.infer<typeof BrowserActionReceipt>,
2506
+ ): z.infer<typeof CompactBrowserActionReceipt> {
2507
+ return {
2508
+ targetId: receipt.targetId,
2509
+ state: receipt.state,
2510
+ ...(receipt.error ? { error: receipt.error } : {}),
2511
+ ...(receipt.targets && receipt.targets.length > 0 ? { targets: receipt.targets } : {}),
2512
+ ...(receipt.observation
2513
+ ? { observation: projectBrowserObservation(receipt.observation, "compact") }
2514
+ : {}),
2515
+ };
2516
+ }
2517
+
2108
2518
  function readBrowserObservation(
2109
2519
  observation: z.infer<typeof BrowserObservation>,
2110
2520
  input: z.output<typeof BrowserReadInput>,