dsh-builtin-browser 0.1.8 → 0.1.9

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.
@@ -36,6 +36,8 @@ export interface Config {
36
36
 
37
37
  /** Per-task browser sessions, keyed by the calling DSH session id. */
38
38
  const sessionsByTask = new Map<string, BrowserSessionId>()
39
+ /** In-flight first-open per task key, so concurrent first calls share one session. */
40
+ const pendingOpens = new Map<string, Promise<BrowserSessionId>>()
39
41
 
40
42
  /**
41
43
  * Action restriction state: an allow-list of browser tool names, or undefined
@@ -66,24 +68,29 @@ function taskKey(exec: { agent?: { id?: string } } | undefined): string {
66
68
 
67
69
  /**
68
70
  * Resolve the calling task's browser session, opening one on first use.
71
+ * Concurrent first calls for the same key share a single open.
69
72
  * @param browser - the seam service.
70
73
  * @param key - the task key (see {@link taskKey}).
71
74
  * @returns the task's session id.
72
75
  */
73
76
  async function ensureSession(browser: NonNullable<Context['browser']>, key: string): Promise<BrowserSessionId> {
74
- let session = sessionsByTask.get(key)
75
- if (session === undefined) {
76
- session = await browser.open()
77
- sessionsByTask.set(key, session)
78
- }
79
- return session
77
+ const existing = sessionsByTask.get(key)
78
+ if (existing !== undefined) return existing
79
+ const pending = pendingOpens.get(key)
80
+ if (pending !== undefined) return pending
81
+ const opening = browser.open().then(
82
+ session => { sessionsByTask.set(key, session); pendingOpens.delete(key); return session },
83
+ error => { pendingOpens.delete(key); throw error },
84
+ )
85
+ pendingOpens.set(key, opening)
86
+ return opening
80
87
  }
81
88
 
82
- /** Coerce a tool-provided string value back to boolean/number when it looks like one. */
89
+ /** Coerce a tool-provided string value back to boolean/number only when lossless. */
83
90
  function parseFillValue(v: string | undefined): string | number | boolean {
84
91
  if (v === 'true') return true
85
92
  if (v === 'false') return false
86
- if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v)) return Number(v)
93
+ if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v) && String(Number(v)) === v) return Number(v)
87
94
  return v ?? ''
88
95
  }
89
96
 
@@ -108,7 +115,8 @@ function formatSnapshot(snapshot: {
108
115
  /** Register all browser tools with `ctx.tools`. */
109
116
  export function apply(ctx: Context, config: Config = {}): void {
110
117
  const timeoutMs = config.timeoutMs ?? 60_000
111
- if (config.allowedActions !== undefined) restrictedTo = [...config.allowedActions]
118
+ // Re-apply resets the restriction: an omitted allowedActions lifts it.
119
+ restrictedTo = config.allowedActions !== undefined ? [...config.allowedActions] : undefined
112
120
 
113
121
  ctx.systemPrompt.section({
114
122
  name: 'tool:browser',
@@ -132,6 +140,7 @@ export function apply(ctx: Context, config: Config = {}): void {
132
140
  properties: {
133
141
  url: { type: 'string', required: true },
134
142
  title: { type: 'string' },
143
+ truncated: { type: 'boolean' },
135
144
  elements: {
136
145
  type: 'array',
137
146
  required: true,
@@ -193,6 +202,7 @@ export function apply(ctx: Context, config: Config = {}): void {
193
202
  properties: {
194
203
  url: { type: 'string', required: true },
195
204
  title: { type: 'string' },
205
+ truncated: { type: 'boolean' },
196
206
  elements: {
197
207
  type: 'array',
198
208
  required: true,
@@ -571,6 +581,7 @@ export function apply(ctx: Context, config: Config = {}): void {
571
581
  timeoutMs,
572
582
  isConcurrencySafe: () => true,
573
583
  async execute(args, exec) {
584
+ assertAllowed('browser_switch_tab')
574
585
  const browser = ctx.get('browser')
575
586
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
576
587
  const session = await ensureSession(browser, taskKey(exec))
@@ -653,9 +664,11 @@ export function apply(ctx: Context, config: Config = {}): void {
653
664
  if (entries.length === 0) return [{ type: 'text', text: '(no recorded operations yet)' }]
654
665
  return [{
655
666
  type: 'text',
656
- text: entries.map(e =>
657
- `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${JSON.stringify(e.params)}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`,
658
- ).join('\n'),
667
+ text: entries.map(e => {
668
+ const rawParams = JSON.stringify(e.params)
669
+ const shownParams = rawParams.length > 300 ? rawParams.slice(0, 300) + '…' : rawParams
670
+ return `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${shownParams}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`
671
+ }).join('\n'),
659
672
  }]
660
673
  },
661
674
  },
@@ -761,9 +774,7 @@ export function apply(ctx: Context, config: Config = {}): void {
761
774
  async execute(_args, exec) {
762
775
  const browser = ctx.get('browser')
763
776
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
764
- const key = taskKey(exec)
765
- const session = sessionsByTask.get(key) ?? await browser.open()
766
- sessionsByTask.set(key, session)
777
+ const session = await ensureSession(browser, taskKey(exec))
767
778
  const tabs = await browser.listTabs(session)
768
779
  return { session, tabs: tabs.map(t => ({ id: t.id, url: t.url, active: t.active })) }
769
780
  },
@@ -786,8 +797,13 @@ export function apply(ctx: Context, config: Config = {}): void {
786
797
  const key = taskKey(exec)
787
798
  const session = sessionsByTask.get(key)
788
799
  if (session !== undefined) {
789
- await browser.close(session)
790
- sessionsByTask.delete(key)
800
+ try {
801
+ await browser.close(session)
802
+ } finally {
803
+ // Always forget the mapping so the next call opens a fresh session,
804
+ // even if the provider close threw (the session is half-closed).
805
+ sessionsByTask.delete(key)
806
+ }
791
807
  }
792
808
  return { reset: true }
793
809
  },
@@ -811,8 +827,15 @@ export function apply(ctx: Context, config: Config = {}): void {
811
827
  isConcurrencySafe: () => false,
812
828
  async execute(args, exec) {
813
829
  // Always allowed so the guard can be lifted.
814
- restrictedTo = (args.allowed ?? []).filter((t: string) => t.startsWith('browser_'))
815
- return { restrictedTo: [...restrictedTo] }
830
+ const allowed = args.allowed ?? []
831
+ const unknown = allowed.filter((t: string) => !t.startsWith('browser_'))
832
+ if (unknown.length > 0) {
833
+ throw new Error(`browser_restrict: unknown tool name(s) ${unknown.map(t => `"${t}"`).join(', ')} (must start with "browser_")`)
834
+ }
835
+ // Empty list (or omitted) lifts the restriction; a non-empty list is the
836
+ // new allow-list.
837
+ restrictedTo = allowed.length === 0 ? undefined : [...allowed]
838
+ return { restrictedTo: restrictedTo === undefined ? [] : [...restrictedTo] }
816
839
  },
817
840
  }))
818
841
 
@@ -854,8 +877,8 @@ export function apply(ctx: Context, config: Config = {}): void {
854
877
 
855
878
  /** Test hook: inspect and reset the plugin-level session map (used by tests). */
856
879
  export const internals = {
857
- /** The per-task session map (task key -> provider session id). */
858
- get sessions(): ReadonlyMap<string, BrowserSessionId> { return sessionsByTask },
880
+ /** A copy of the per-task session map (task key -> provider session id). */
881
+ get sessions(): ReadonlyMap<string, BrowserSessionId> { return new Map(sessionsByTask) },
859
882
  /** Drop one task's mapping without closing the provider session. */
860
883
  clearSession(key = 'default'): void { sessionsByTask.delete(key) },
861
884
  }