@effect-uai/core 0.9.0 → 0.10.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.
- package/dist/{AiError-CrdR1EQi.d.mts → AiError-Bf1pq4BB.d.mts} +8 -3
- package/dist/{AiError-CrdR1EQi.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
- package/dist/{Audio-DDSKCkSe.d.mts → Audio-CR7CSljJ.d.mts} +2 -2
- package/dist/{Audio-DDSKCkSe.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
- package/dist/{Image-C_Zjpwhc.d.mts → Image-BZmKfIdq.d.mts} +2 -2
- package/dist/{Image-C_Zjpwhc.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
- package/dist/{Items-CgKD6Npw.d.mts → Items-BnIduEV2.d.mts} +9 -9
- package/dist/{Items-CgKD6Npw.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
- package/dist/{Loop-B5_cqumi.d.mts → Loop-D8nWpowc.d.mts} +2 -2
- package/dist/{Loop-B5_cqumi.d.mts.map → Loop-D8nWpowc.d.mts.map} +1 -1
- package/dist/{Media-DOLyaygI.d.mts → Media-D_CpcM1Z.d.mts} +1 -1
- package/dist/{Media-DOLyaygI.d.mts.map → Media-D_CpcM1Z.d.mts.map} +1 -1
- package/dist/{StructuredFormat-CMJFD3sn.d.mts → StructuredFormat-DHaUJBdd.d.mts} +1 -1
- package/dist/{StructuredFormat-CMJFD3sn.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
- package/dist/{Tool-0XUIT2A2.d.mts → Tool-2lC_XMth.d.mts} +64 -12
- package/dist/Tool-2lC_XMth.d.mts.map +1 -0
- package/dist/{ToolEvent-DYrAUJGs.d.mts → ToolEvent-cWZ2BnbS.d.mts} +2 -2
- package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
- package/dist/{ToolResult-BZd-cEWW.d.mts → ToolResult-CL4hK_9z.d.mts} +10 -4
- package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
- package/dist/{Toolkit-BKC8b8Xo.d.mts → Toolkit-Dr9l_hNy.d.mts} +64 -23
- package/dist/Toolkit-Dr9l_hNy.d.mts.map +1 -0
- package/dist/browser/Browser.d.mts +190 -0
- package/dist/browser/Browser.d.mts.map +1 -0
- package/dist/browser/Browser.mjs +34 -0
- package/dist/browser/Browser.mjs.map +1 -0
- package/dist/browser/BrowserError.d.mts +141 -0
- package/dist/browser/BrowserError.d.mts.map +1 -0
- package/dist/browser/BrowserError.mjs +89 -0
- package/dist/browser/BrowserError.mjs.map +1 -0
- package/dist/browser/BrowserTool.d.mts +58 -0
- package/dist/browser/BrowserTool.d.mts.map +1 -0
- package/dist/browser/BrowserTool.mjs +86 -0
- package/dist/browser/BrowserTool.mjs.map +1 -0
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +22 -3
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Audio.d.mts +1 -1
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Media.d.mts +1 -1
- package/dist/domain/Music.d.mts +1 -1
- package/dist/domain/Turn.d.mts +3 -3
- package/dist/embedding-model/Embedding.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/index.d.mts +17 -12
- package/dist/index.mjs +14 -9
- package/dist/language-model/LanguageModel.d.mts +4 -4
- package/dist/loop/Loop.d.mts +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +2 -2
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/retry/Retry.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
- package/dist/structured-format/StructuredFormat.d.mts +1 -1
- package/dist/testing/MockMusicGenerator.d.mts +1 -1
- package/dist/testing/MockProvider.d.mts +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
- package/dist/tool/Approval.d.mts +19 -7
- package/dist/tool/Approval.d.mts.map +1 -1
- package/dist/tool/Approval.mjs +18 -11
- package/dist/tool/Approval.mjs.map +1 -1
- package/dist/tool/Approval.test.mjs +46 -7
- package/dist/tool/Approval.test.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +2 -2
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +86 -22
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +1 -1
- package/dist/tool/ToolEvent.mjs +5 -0
- package/dist/tool/ToolEvent.mjs.map +1 -1
- package/dist/tool/ToolResult.d.mts +2 -2
- package/dist/tool/ToolResult.mjs +15 -6
- package/dist/tool/ToolResult.mjs.map +1 -1
- package/dist/tool/Toolkit.d.mts +2 -2
- package/dist/tool/Toolkit.mjs +62 -22
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +163 -9
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +2 -2
- package/dist/web-read/WebRead.d.mts +44 -0
- package/dist/web-read/WebRead.d.mts.map +1 -0
- package/dist/web-read/WebRead.mjs +18 -0
- package/dist/web-read/WebRead.mjs.map +1 -0
- package/dist/web-read/WebReadTool.d.mts +57 -0
- package/dist/web-read/WebReadTool.d.mts.map +1 -0
- package/dist/web-read/WebReadTool.mjs +59 -0
- package/dist/web-read/WebReadTool.mjs.map +1 -0
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +12 -6
- package/dist/web-search/WebSearchTool.d.mts.map +1 -1
- package/dist/web-search/WebSearchTool.mjs +10 -5
- package/dist/web-search/WebSearchTool.mjs.map +1 -1
- package/package.json +21 -1
- package/src/browser/Browser.ts +268 -0
- package/src/browser/BrowserError.ts +145 -0
- package/src/browser/BrowserTool.ts +161 -0
- package/src/domain/AiError.ts +27 -1
- package/src/index.ts +5 -0
- package/src/tool/Approval.test.ts +55 -5
- package/src/tool/Approval.ts +72 -27
- package/src/tool/Tool.ts +146 -47
- package/src/tool/ToolEvent.ts +5 -0
- package/src/tool/ToolResult.ts +14 -8
- package/src/tool/Toolkit.test.ts +171 -2
- package/src/tool/Toolkit.ts +173 -60
- package/src/web-read/WebRead.ts +70 -0
- package/src/web-read/WebReadTool.ts +96 -0
- package/src/web-search/WebSearchTool.ts +11 -5
- package/dist/Tool-0XUIT2A2.d.mts.map +0 -1
- package/dist/ToolEvent-DYrAUJGs.d.mts.map +0 -1
- package/dist/ToolResult-BZd-cEWW.d.mts.map +0 -1
- package/dist/Toolkit-BKC8b8Xo.d.mts.map +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Data, Duration, Match } from "effect"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Failed to provision a browser session: the transport could not connect,
|
|
5
|
+
* a launched engine did not come up, or the provider refused to mint a
|
|
6
|
+
* session. Provider's structured reason on `reason`; the raw transport /
|
|
7
|
+
* SDK error on `raw`.
|
|
8
|
+
*/
|
|
9
|
+
export class BrowserCreateFailed extends Data.TaggedError("BrowserCreateFailed")<{
|
|
10
|
+
provider: string
|
|
11
|
+
reason?: string
|
|
12
|
+
raw: unknown
|
|
13
|
+
}> {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The requested session id does not exist. Raised by `attach` / `destroy`
|
|
17
|
+
* against a stale or never-known id. Distinct from
|
|
18
|
+
* {@link BrowserSessionExpired}, which is a session that existed and has
|
|
19
|
+
* since ended.
|
|
20
|
+
*/
|
|
21
|
+
export class BrowserSessionNotFound extends Data.TaggedError("BrowserSessionNotFound")<{
|
|
22
|
+
provider: string
|
|
23
|
+
id: string
|
|
24
|
+
}> {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An operation was issued against a session that has ended: it reached its
|
|
28
|
+
* max lifetime (the create-time `timeout`), was destroyed, or the provider
|
|
29
|
+
* reaped it. The handle is dead; `create` or `attach` a live session.
|
|
30
|
+
* Distinct from {@link BrowserTimeout} (a single live operation was too
|
|
31
|
+
* slow).
|
|
32
|
+
*/
|
|
33
|
+
export class BrowserSessionExpired extends Data.TaggedError("BrowserSessionExpired")<{
|
|
34
|
+
provider: string
|
|
35
|
+
id: string
|
|
36
|
+
}> {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A single operation exceeded its own time budget while the session was
|
|
40
|
+
* still alive (a navigation that never settled, a `waitFor` selector that
|
|
41
|
+
* never appeared). Carries the breached budget for observability.
|
|
42
|
+
*/
|
|
43
|
+
export class BrowserTimeout extends Data.TaggedError("BrowserTimeout")<{
|
|
44
|
+
provider: string
|
|
45
|
+
operation: "create" | "attach" | "navigate" | "action" | "query"
|
|
46
|
+
budget?: Duration.Duration
|
|
47
|
+
raw?: unknown
|
|
48
|
+
}> {}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An interaction or observation failed at the transport / protocol layer:
|
|
52
|
+
* a selector resolved to nothing, an element was not actionable, an
|
|
53
|
+
* `evaluate` script threw, a navigation errored. `selector` and `reason`
|
|
54
|
+
* carry what is known.
|
|
55
|
+
*/
|
|
56
|
+
export class BrowserActionFailed extends Data.TaggedError("BrowserActionFailed")<{
|
|
57
|
+
provider: string
|
|
58
|
+
operation: "navigate" | "interact" | "observe" | "evaluate" | "cookies"
|
|
59
|
+
selector?: string
|
|
60
|
+
reason?: string
|
|
61
|
+
raw: unknown
|
|
62
|
+
}> {}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Provider rejected the request because an account / project quota or
|
|
66
|
+
* concurrency limit was exceeded. Separate from {@link BrowserCreateFailed}
|
|
67
|
+
* so callers can back off and retry.
|
|
68
|
+
*/
|
|
69
|
+
export class BrowserQuotaExceeded extends Data.TaggedError("BrowserQuotaExceeded")<{
|
|
70
|
+
provider: string
|
|
71
|
+
reason?: string
|
|
72
|
+
raw: unknown
|
|
73
|
+
}> {}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Caller's request shape is malformed for the wired provider. Adapters
|
|
77
|
+
* should reject at the request-schema layer where possible; this is the
|
|
78
|
+
* runtime fallback.
|
|
79
|
+
*/
|
|
80
|
+
export class BrowserInvalidRequest extends Data.TaggedError("BrowserInvalidRequest")<{
|
|
81
|
+
provider: string
|
|
82
|
+
param?: string
|
|
83
|
+
reason?: string
|
|
84
|
+
raw?: unknown
|
|
85
|
+
}> {}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Transport could not authenticate against the provider. Subtype mirrors
|
|
89
|
+
* the model-side `AiError.AuthFailed` shape for consistency.
|
|
90
|
+
*/
|
|
91
|
+
export type BrowserAuthSubtype = "auth" | "permission" | "billing" | "quota"
|
|
92
|
+
|
|
93
|
+
export class BrowserAuthFailed extends Data.TaggedError("BrowserAuthFailed")<{
|
|
94
|
+
provider: string
|
|
95
|
+
subtype: BrowserAuthSubtype
|
|
96
|
+
raw: unknown
|
|
97
|
+
}> {}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The wired provider does not implement the requested operation for these
|
|
101
|
+
* inputs. Distinct from {@link BrowserInvalidRequest} (malformed request)
|
|
102
|
+
* and from compile-time capability markers gating blanket provider gaps.
|
|
103
|
+
*/
|
|
104
|
+
export class BrowserUnsupported extends Data.TaggedError("BrowserUnsupported")<{
|
|
105
|
+
provider: string
|
|
106
|
+
capability: string
|
|
107
|
+
reason?: string
|
|
108
|
+
}> {}
|
|
109
|
+
|
|
110
|
+
export type BrowserError =
|
|
111
|
+
| BrowserCreateFailed
|
|
112
|
+
| BrowserSessionNotFound
|
|
113
|
+
| BrowserSessionExpired
|
|
114
|
+
| BrowserTimeout
|
|
115
|
+
| BrowserActionFailed
|
|
116
|
+
| BrowserQuotaExceeded
|
|
117
|
+
| BrowserInvalidRequest
|
|
118
|
+
| BrowserAuthFailed
|
|
119
|
+
| BrowserUnsupported
|
|
120
|
+
|
|
121
|
+
const withReason = (base: string, reason: string | undefined): string =>
|
|
122
|
+
reason === undefined ? base : `${base}: ${reason}`
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Short human-readable description of an error, for logs and model-facing
|
|
126
|
+
* failure messages. Prose, not a contract; branch on `_tag` instead.
|
|
127
|
+
*/
|
|
128
|
+
export const describe: (e: BrowserError) => string = Match.type<BrowserError>().pipe(
|
|
129
|
+
Match.discriminatorsExhaustive("_tag")({
|
|
130
|
+
BrowserCreateFailed: (e) => withReason("the browser session could not be created", e.reason),
|
|
131
|
+
BrowserSessionNotFound: () => "the browser session does not exist",
|
|
132
|
+
BrowserSessionExpired: () => "the browser session has expired",
|
|
133
|
+
BrowserTimeout: (e) => `the ${e.operation} operation timed out`,
|
|
134
|
+
BrowserActionFailed: (e) =>
|
|
135
|
+
withReason(
|
|
136
|
+
`the ${e.operation} failed${e.selector === undefined ? "" : ` on ${e.selector}`}`,
|
|
137
|
+
e.reason,
|
|
138
|
+
),
|
|
139
|
+
BrowserQuotaExceeded: (e) => withReason("the browser quota is exceeded", e.reason),
|
|
140
|
+
BrowserInvalidRequest: (e) => withReason("the request was invalid", e.reason),
|
|
141
|
+
BrowserAuthFailed: (e) => `browser authentication failed (${e.subtype})`,
|
|
142
|
+
BrowserUnsupported: (e) =>
|
|
143
|
+
withReason(`this browser does not support ${e.capability}`, e.reason),
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect"
|
|
2
|
+
import * as Tool from "../tool/Tool.js"
|
|
3
|
+
import * as Toolkit from "../tool/Toolkit.js"
|
|
4
|
+
import type { BrowserSession } from "./Browser.js"
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Model-facing argument schemas. Annotated so the descriptions reach the
|
|
8
|
+
// model through the rendered JSON Schema (JSDoc does not).
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const GotoArgs = Schema.Struct({
|
|
12
|
+
url: Schema.String.annotate({ description: "Absolute URL to navigate to." }),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const ClickArgs = Schema.Struct({
|
|
16
|
+
ref: Schema.String.annotate({
|
|
17
|
+
description: "A CSS selector, or an element @ref if a page-reading tool provides one.",
|
|
18
|
+
}),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const FillArgs = Schema.Struct({
|
|
22
|
+
ref: Schema.String.annotate({
|
|
23
|
+
description: "A CSS selector (or element @ref) of the input to fill.",
|
|
24
|
+
}),
|
|
25
|
+
text: Schema.String.annotate({ description: "The text to put in the input." }),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const PressArgs = Schema.Struct({
|
|
29
|
+
key: Schema.String.annotate({
|
|
30
|
+
description: 'Key to press, e.g. "Enter" to submit the focused input, "Tab", "Escape".',
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const ScrollArgs = Schema.Struct({
|
|
35
|
+
direction: Schema.Literals(["up", "down"]).annotate({
|
|
36
|
+
description: "Scroll the viewport up or down.",
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Uniform action outcome: the model always learns where the action left it.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const currentUrl = (session: BrowserSession): Effect.Effect<string> =>
|
|
45
|
+
session.evaluate("location.href").pipe(
|
|
46
|
+
Effect.flatMap(Schema.decodeUnknownEffect(Schema.String)),
|
|
47
|
+
Effect.orElseSucceed(() => "(unknown)"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const okAt = (session: BrowserSession): Effect.Effect<string> =>
|
|
51
|
+
Effect.map(currentUrl(session), (url) => `ok (now at ${url})`)
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// The canonical browser action tools, each closed over a live `BrowserSession`
|
|
55
|
+
// and failing with the full `BrowserError`. `Output` is a compact outcome
|
|
56
|
+
// (`ok (now at <url>)`), not a page dump - a page-reading tool is the recipe's
|
|
57
|
+
// to define, so history does not accumulate a full page per action.
|
|
58
|
+
//
|
|
59
|
+
// A failed action (stale ref, dead control, timeout) fails typed on the error
|
|
60
|
+
// channel. Whether the model gets to recover from it is the loop's decision:
|
|
61
|
+
// wrap the toolkit in `Toolkit.describeFailures(BrowserError.describe)` to
|
|
62
|
+
// surface failures to the model, or map selectively to keep session/infra
|
|
63
|
+
// errors (e.g. `BrowserSessionExpired`) fatal.
|
|
64
|
+
//
|
|
65
|
+
// Browser actions are order-dependent (fill, then press Enter): run a turn's
|
|
66
|
+
// calls with `{ concurrency: 1 }`.
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export const gotoTool = (session: BrowserSession) =>
|
|
70
|
+
Tool.make({
|
|
71
|
+
name: "browser_goto",
|
|
72
|
+
description: "Navigate the browser to an absolute URL.",
|
|
73
|
+
inputSchema: Tool.fromEffectSchema(GotoArgs),
|
|
74
|
+
run: (args) =>
|
|
75
|
+
Effect.andThen(session.goto(args.url), okAt(session)).pipe(
|
|
76
|
+
Effect.withSpan("browser_goto", {
|
|
77
|
+
kind: "client",
|
|
78
|
+
attributes: { "browser.url": args.url },
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export const clickTool = (session: BrowserSession) =>
|
|
84
|
+
Tool.make({
|
|
85
|
+
name: "browser_click",
|
|
86
|
+
description: "Click the element named by ref. A click may navigate to a new page.",
|
|
87
|
+
inputSchema: Tool.fromEffectSchema(ClickArgs),
|
|
88
|
+
run: (args) =>
|
|
89
|
+
Effect.andThen(session.click(args.ref), okAt(session)).pipe(
|
|
90
|
+
Effect.withSpan("browser_click", {
|
|
91
|
+
kind: "client",
|
|
92
|
+
attributes: { "browser.ref": args.ref },
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
export const fillTool = (session: BrowserSession) =>
|
|
98
|
+
Tool.make({
|
|
99
|
+
name: "browser_fill",
|
|
100
|
+
description:
|
|
101
|
+
"Put text into the input named by ref. Filling alone does not submit; press Enter to submit the input.",
|
|
102
|
+
inputSchema: Tool.fromEffectSchema(FillArgs),
|
|
103
|
+
run: (args) =>
|
|
104
|
+
Effect.andThen(session.fill(args.ref, args.text), okAt(session)).pipe(
|
|
105
|
+
Effect.withSpan("browser_fill", {
|
|
106
|
+
kind: "client",
|
|
107
|
+
attributes: { "browser.ref": args.ref },
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
export const pressTool = (session: BrowserSession) =>
|
|
113
|
+
Tool.make({
|
|
114
|
+
name: "browser_press",
|
|
115
|
+
description: "Press a keyboard key against the focused element.",
|
|
116
|
+
inputSchema: Tool.fromEffectSchema(PressArgs),
|
|
117
|
+
run: (args) =>
|
|
118
|
+
Effect.andThen(session.press(args.key), okAt(session)).pipe(
|
|
119
|
+
Effect.withSpan("browser_press", {
|
|
120
|
+
kind: "client",
|
|
121
|
+
attributes: { "browser.key": args.key },
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
export const scrollTool = (session: BrowserSession) =>
|
|
127
|
+
Tool.make({
|
|
128
|
+
name: "browser_scroll",
|
|
129
|
+
description:
|
|
130
|
+
"Scroll the viewport. Only useful to bring an element into view - a page-reading tool's text is already scroll-independent.",
|
|
131
|
+
inputSchema: Tool.fromEffectSchema(ScrollArgs),
|
|
132
|
+
run: (args) =>
|
|
133
|
+
Effect.andThen(session.scroll({ direction: args.direction }), okAt(session)).pipe(
|
|
134
|
+
Effect.withSpan("browser_scroll", {
|
|
135
|
+
kind: "client",
|
|
136
|
+
attributes: { "browser.direction": args.direction },
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The canonical browser action verbs bundled as a `Toolkit`, closed over a
|
|
143
|
+
* live `BrowserSession`. Compose it with a recipe-local page-reading tool and
|
|
144
|
+
* whatever finish/signal tools the agent needs:
|
|
145
|
+
*
|
|
146
|
+
* const toolkit = yield* Toolkit.compose(
|
|
147
|
+
* browserToolkit(session),
|
|
148
|
+
* Toolkit.make(readPageTool(session), finishTool),
|
|
149
|
+
* )
|
|
150
|
+
*
|
|
151
|
+
* For an à-la-carte subset, use the individual `gotoTool` / `clickTool` / …
|
|
152
|
+
* constructors directly. Run a turn's calls with `{ concurrency: 1 }`.
|
|
153
|
+
*/
|
|
154
|
+
export const browserToolkit = (session: BrowserSession) =>
|
|
155
|
+
Toolkit.make(
|
|
156
|
+
gotoTool(session),
|
|
157
|
+
clickTool(session),
|
|
158
|
+
fillTool(session),
|
|
159
|
+
pressTool(session),
|
|
160
|
+
scrollTool(session),
|
|
161
|
+
)
|
package/src/domain/AiError.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Data, Duration } from "effect"
|
|
1
|
+
import { Data, Duration, Match } from "effect"
|
|
2
2
|
|
|
3
3
|
export type Scope = "rpm" | "tpm" | "rpd" | "tpd"
|
|
4
4
|
|
|
@@ -112,3 +112,29 @@ export type AiError =
|
|
|
112
112
|
| IncompleteTurn
|
|
113
113
|
| GenerationFailed
|
|
114
114
|
| Unsupported
|
|
115
|
+
|
|
116
|
+
const withReason = (base: string, reason: string | undefined): string =>
|
|
117
|
+
reason === undefined ? base : `${base}: ${reason}`
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Short human-readable description of an error, for logs and model-facing
|
|
121
|
+
* failure messages. Prose, not a contract; branch on `_tag` instead.
|
|
122
|
+
*/
|
|
123
|
+
export const describe: (e: AiError) => string = Match.type<AiError>().pipe(
|
|
124
|
+
Match.discriminatorsExhaustive("_tag")({
|
|
125
|
+
RateLimited: (e) => `rate limited by ${e.provider}; try again later`,
|
|
126
|
+
Unavailable: (e) => `${e.provider} is temporarily unavailable`,
|
|
127
|
+
Timeout: (e) => `the ${e.provider} request timed out`,
|
|
128
|
+
ContentFiltered: (e) => withReason(`blocked by ${e.provider}'s content filter`, e.reason),
|
|
129
|
+
ContextLengthExceeded: (e) => `the request exceeded ${e.provider}'s context limit`,
|
|
130
|
+
InvalidRequest: (e) =>
|
|
131
|
+
e.param === undefined
|
|
132
|
+
? `${e.provider} rejected the request`
|
|
133
|
+
: `${e.provider} rejected the request (param: ${e.param})`,
|
|
134
|
+
AuthFailed: (e) => `authentication against ${e.provider} failed (${e.subtype})`,
|
|
135
|
+
Cancelled: (e) => `the ${e.provider} request was cancelled`,
|
|
136
|
+
IncompleteTurn: () => "the provider stream ended without completing",
|
|
137
|
+
GenerationFailed: (e) => e.message ?? `${e.provider} failed mid-generation`,
|
|
138
|
+
Unsupported: (e) => withReason(`${e.provider} does not support ${e.capability}`, e.reason),
|
|
139
|
+
}),
|
|
140
|
+
)
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export * as AiError from "./domain/AiError.js"
|
|
2
2
|
export * as Audio from "./domain/Audio.js"
|
|
3
|
+
export * as Browser from "./browser/Browser.js"
|
|
4
|
+
export * as BrowserError from "./browser/BrowserError.js"
|
|
5
|
+
export * as BrowserTool from "./browser/BrowserTool.js"
|
|
3
6
|
export * as Capabilities from "./capabilities/Capabilities.js"
|
|
4
7
|
export * as Image from "./domain/Image.js"
|
|
5
8
|
export * as Items from "./domain/Items.js"
|
|
@@ -18,6 +21,8 @@ export * as SandboxImage from "./sandbox/SandboxImage.js"
|
|
|
18
21
|
export * as SandboxNetwork from "./sandbox/SandboxNetwork.js"
|
|
19
22
|
export * as SpeechSynthesizer from "./speech-synthesizer/SpeechSynthesizer.js"
|
|
20
23
|
export * as Transcriber from "./transcriber/Transcriber.js"
|
|
24
|
+
export * as WebRead from "./web-read/WebRead.js"
|
|
25
|
+
export * as WebReadTool from "./web-read/WebReadTool.js"
|
|
21
26
|
export * as WebSearch from "./web-search/WebSearch.js"
|
|
22
27
|
export * as WebSearchTool from "./web-search/WebSearchTool.js"
|
|
23
28
|
export * as Vector from "./math/Vector.js"
|
|
@@ -15,7 +15,13 @@ import { describe, expect, it } from "vitest"
|
|
|
15
15
|
import * as Items from "../domain/Items.js"
|
|
16
16
|
import { findUnansweredCalls, cancelAllPending, isReconciled } from "./HistoryCheck.js"
|
|
17
17
|
import { type ToolResult, isFailure, isOk, toToolCallOutput } from "./ToolResult.js"
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
type ApprovalMapEntry,
|
|
20
|
+
type ApprovalDecision,
|
|
21
|
+
type Verdict,
|
|
22
|
+
fromMap,
|
|
23
|
+
fromQueue,
|
|
24
|
+
} from "./Approval.js"
|
|
19
25
|
import { fromEffectSchema, make as makeTool } from "./Tool.js"
|
|
20
26
|
import { make as makeToolkit, run } from "./Toolkit.js"
|
|
21
27
|
import { ToolEvent, isApprovalRequested, isProgress, isOutput } from "./ToolEvent.js"
|
|
@@ -249,6 +255,50 @@ describe("fromQueue + run", () => {
|
|
|
249
255
|
reason: "too risky",
|
|
250
256
|
})
|
|
251
257
|
})
|
|
258
|
+
|
|
259
|
+
it("router retires per round, so sequential rounds over one queue both resolve", async () => {
|
|
260
|
+
// A verdict-per-round over one shared queue and scope. The old
|
|
261
|
+
// never-terminating router would still be alive for round 1 and steal
|
|
262
|
+
// round 2's verdict (not in its map), hanging round 2.
|
|
263
|
+
const decisions = await Effect.runPromise(
|
|
264
|
+
Effect.scoped(
|
|
265
|
+
Effect.gen(function* () {
|
|
266
|
+
const verdicts = yield* Queue.unbounded<Verdict>()
|
|
267
|
+
const round = (id: string) => [fc(id, "delete_database", { name: "prod" })]
|
|
268
|
+
|
|
269
|
+
const r1 = yield* fromQueue(isSensitive, verdicts)(round("c1"))
|
|
270
|
+
yield* Queue.offer(verdicts, { call_id: "c1", decision: "approve" })
|
|
271
|
+
const d1 = Array.from(yield* Stream.runCollect(r1.decisions))
|
|
272
|
+
|
|
273
|
+
const r2 = yield* fromQueue(isSensitive, verdicts)(round("c2"))
|
|
274
|
+
yield* Queue.offer(verdicts, { call_id: "c2", decision: "deny", reason: "no" })
|
|
275
|
+
const d2 = Array.from(yield* Stream.runCollect(r2.decisions))
|
|
276
|
+
|
|
277
|
+
return { d1, d2 }
|
|
278
|
+
}),
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
expect(decisions.d1[0]).toMatchObject({ _tag: "Approved" })
|
|
282
|
+
expect(decisions.d2[0]).toMatchObject({ _tag: "Rejected", result: { kind: "denied" } })
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it("timeout resolves an unanswered gated call as cancelled", async () => {
|
|
286
|
+
const decisions = await Effect.runPromise(
|
|
287
|
+
Effect.scoped(
|
|
288
|
+
Effect.gen(function* () {
|
|
289
|
+
const verdicts = yield* Queue.unbounded<Verdict>()
|
|
290
|
+
const { decisions } = yield* fromQueue(isSensitive, verdicts, { timeout: "10 millis" })([
|
|
291
|
+
fc("c1", "delete_database", { name: "prod" }),
|
|
292
|
+
])
|
|
293
|
+
return Array.from(yield* Stream.runCollect(decisions))
|
|
294
|
+
}),
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
expect(decisions[0]).toMatchObject({
|
|
298
|
+
_tag: "Rejected",
|
|
299
|
+
result: { _tag: "Failure", kind: "cancelled" },
|
|
300
|
+
})
|
|
301
|
+
})
|
|
252
302
|
})
|
|
253
303
|
|
|
254
304
|
// ---------------------------------------------------------------------------
|
|
@@ -313,7 +363,7 @@ describe("toToolCallOutput", () => {
|
|
|
313
363
|
expect(JSON.parse(out.output)).toEqual({ count: 3 })
|
|
314
364
|
})
|
|
315
365
|
|
|
316
|
-
it("
|
|
366
|
+
it("renders a Failure with reason as a nested error object", () => {
|
|
317
367
|
const r: ToolResult = {
|
|
318
368
|
_tag: "Failure",
|
|
319
369
|
call_id: "c2",
|
|
@@ -322,10 +372,10 @@ describe("toToolCallOutput", () => {
|
|
|
322
372
|
reason: "spam concern",
|
|
323
373
|
}
|
|
324
374
|
const out = toToolCallOutput(r)
|
|
325
|
-
expect(JSON.parse(out.output)).toEqual({ kind: "denied",
|
|
375
|
+
expect(JSON.parse(out.output)).toEqual({ error: { kind: "denied", message: "spam concern" } })
|
|
326
376
|
})
|
|
327
377
|
|
|
328
|
-
it("
|
|
378
|
+
it("renders a Failure without reason (omits message)", () => {
|
|
329
379
|
const r: ToolResult = {
|
|
330
380
|
_tag: "Failure",
|
|
331
381
|
call_id: "c3",
|
|
@@ -333,7 +383,7 @@ describe("toToolCallOutput", () => {
|
|
|
333
383
|
kind: "cancelled",
|
|
334
384
|
}
|
|
335
385
|
const out = toToolCallOutput(r)
|
|
336
|
-
expect(JSON.parse(out.output)).toEqual({ kind: "cancelled" })
|
|
386
|
+
expect(JSON.parse(out.output)).toEqual({ error: { kind: "cancelled" } })
|
|
337
387
|
})
|
|
338
388
|
})
|
|
339
389
|
|
package/src/tool/Approval.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* results must be returned to the model. Tool execution stays explicit at
|
|
6
6
|
* the recipe boundary via `Toolkit.run`.
|
|
7
7
|
*/
|
|
8
|
-
import { Data, Deferred, Effect, Queue, Scope, Stream } from "effect"
|
|
8
|
+
import { Data, Deferred, type Duration, Effect, Queue, Ref, Scope, Stream } from "effect"
|
|
9
9
|
import type { ToolCall } from "../domain/Items.js"
|
|
10
10
|
import { type ToolResult, cancelled, denied } from "./ToolResult.js"
|
|
11
11
|
import { ToolEvent } from "./ToolEvent.js"
|
|
@@ -53,13 +53,30 @@ export type Verdict = {
|
|
|
53
53
|
readonly reason?: string
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export type FromQueueOptions = {
|
|
57
|
+
/**
|
|
58
|
+
* Resolve any still-pending gated call as `cancelled` once this elapses,
|
|
59
|
+
* so a lost or never-sent verdict cannot hang the decisions stream
|
|
60
|
+
* forever. Omit to wait indefinitely.
|
|
61
|
+
*/
|
|
62
|
+
readonly timeout?: Duration.Input
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
/**
|
|
57
66
|
* Queue-backed approval planner. Safe calls are returned immediately in
|
|
58
67
|
* `approved`; gated calls emit `ApprovalRequested` events and later produce
|
|
59
68
|
* one `ApprovalDecision` when their matching verdict arrives.
|
|
69
|
+
*
|
|
70
|
+
* The verdict queue is a single-consumer transport: run one approval round
|
|
71
|
+
* at a time (the router retires once every gated call of the round is
|
|
72
|
+
* resolved). For overlapping rounds, use one queue per round.
|
|
60
73
|
*/
|
|
61
74
|
export const fromQueue =
|
|
62
|
-
(
|
|
75
|
+
(
|
|
76
|
+
predicate: (call: ToolCall) => boolean,
|
|
77
|
+
verdicts: Queue.Dequeue<Verdict>,
|
|
78
|
+
options?: FromQueueOptions,
|
|
79
|
+
) =>
|
|
63
80
|
(
|
|
64
81
|
calls: ReadonlyArray<ToolCall>,
|
|
65
82
|
): Effect.Effect<
|
|
@@ -76,37 +93,65 @@ export const fromQueue =
|
|
|
76
93
|
const approved = calls.filter((call) => !predicate(call))
|
|
77
94
|
|
|
78
95
|
const entries = yield* Effect.forEach(gated, (call) =>
|
|
79
|
-
Deferred.make<
|
|
96
|
+
Deferred.make<ApprovalDecision>().pipe(Effect.map((d) => [call.call_id, d] as const)),
|
|
80
97
|
)
|
|
81
|
-
const deferreds: ReadonlyMap<string, Deferred.Deferred<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
const deferreds: ReadonlyMap<string, Deferred.Deferred<ApprovalDecision>> = new Map(entries)
|
|
99
|
+
const callById: ReadonlyMap<string, ToolCall> = new Map(gated.map((c) => [c.call_id, c]))
|
|
100
|
+
const outstanding = yield* Ref.make(gated.length)
|
|
101
|
+
|
|
102
|
+
// Complete a deferred once; decrement only on the winning transition so
|
|
103
|
+
// a late verdict for an already-resolved call is a no-op.
|
|
104
|
+
const resolve = (call: ToolCall, decision: ApprovalDecision) =>
|
|
105
|
+
Deferred.succeed(deferreds.get(call.call_id)!, decision).pipe(
|
|
106
|
+
Effect.flatMap((won) => (won ? Ref.update(outstanding, (n) => n - 1) : Effect.void)),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Router retires once every gated call is resolved (by verdict or
|
|
110
|
+
// timeout), so sequential rounds over one queue do not accumulate
|
|
111
|
+
// racing routers. Recursive + suspended: stack-safe, no imperative loop.
|
|
112
|
+
const route: Effect.Effect<void> = Effect.suspend(() =>
|
|
113
|
+
Ref.get(outstanding).pipe(
|
|
114
|
+
Effect.flatMap((n) =>
|
|
115
|
+
n <= 0
|
|
116
|
+
? Effect.void
|
|
117
|
+
: Queue.take(verdicts).pipe(
|
|
118
|
+
Effect.flatMap((v) => {
|
|
119
|
+
const call = callById.get(v.call_id)
|
|
120
|
+
return (
|
|
121
|
+
call === undefined
|
|
122
|
+
? Effect.void
|
|
123
|
+
: resolve(
|
|
124
|
+
call,
|
|
125
|
+
v.decision === "approve"
|
|
126
|
+
? approve(call)
|
|
127
|
+
: reject(denied(call, v.reason)),
|
|
128
|
+
)
|
|
129
|
+
).pipe(Effect.andThen(route))
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
93
133
|
),
|
|
94
134
|
)
|
|
95
135
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
Effect.
|
|
103
|
-
|
|
136
|
+
if (gated.length > 0) {
|
|
137
|
+
yield* Effect.forkScoped(route)
|
|
138
|
+
if (options?.timeout !== undefined) {
|
|
139
|
+
yield* Effect.forkScoped(
|
|
140
|
+
Effect.sleep(options.timeout).pipe(
|
|
141
|
+
Effect.andThen(
|
|
142
|
+
Effect.forEach(gated, (call) =>
|
|
143
|
+
resolve(call, reject(cancelled(call, "approval timed out"))),
|
|
104
144
|
),
|
|
105
145
|
),
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const decisions = Stream.fromIterable(gated).pipe(
|
|
152
|
+
Stream.flatMap((call) => Stream.fromEffect(Deferred.await(deferreds.get(call.call_id)!)), {
|
|
153
|
+
concurrency: "unbounded",
|
|
154
|
+
}),
|
|
110
155
|
)
|
|
111
156
|
|
|
112
157
|
const approvalRequests = Stream.fromIterable<ToolEvent>(gated.map(approvalRequested))
|