Skip to content

Commit 041a7c5

Browse files
Step 6: baseline browser-task generalist (#9)
First v0.2 capability. LLM-in-the-loop planner over ref-annotated aria snapshots + aria-ref=eN locator resolution, built on Microsoft.Playwright 1.59 (bumped from 1.50 for AriaSnapshotMode.Ai). Registered tiered chat clients via AddRockBotTieredChatClients with one model aliased across Low/Balanced/High per spec §3.7. - HostAllowlist with *, *.domain, exact-host patterns; empty rejects. - Context-wide RouteAsync aborts off-list document/subframe navs before Playwright issues the request (spec §7.1). - [AIFunction] tools: snapshot / navigate / click / type / wait_for / done / fail. Budget enforced tool-side (default maxSteps=60, ceiling 150) + wall-clock CancellationTokenSource (default 120s, ceiling 600). - Structured JSON output: {status, summary, result, steps, navigations}. - Unit tests: 14 new (HostAllowlist, BrowserTaskCapability via ScriptedChatClient + FakeBrowserAgentPage). - Real-LLM benchmark: 3 Kestrel scenarios, all pass on first attempt against Azure AI Foundry Balanced (5/8/7s). Establishes the unaided floor before step 7 adds priming. - Smoke-tested via docker-compose HTTP gateway (agent-card lists four skills; browser-task returns "Example Domain" in 1 step; off-list URLs rejected). Framework observations captured in docs/framework-feedback.md: AddRockBotTieredChatClients subsumes AddRockBotChatClient (undocumented); no per-request iteration cap on the function-invoking chat client; Playwright aria-ref gating via AriaSnapshotMode.Ai rather than a boolean Ref option. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e55c09d commit 041a7c5

21 files changed

Lines changed: 1683 additions & 23 deletions

CLAUDE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Status
66

7-
Foragent is at **milestone 5 shipped, v0.2 spec adopted, step 6 next**. Three capabilities are live (`fetch-page-title`, `extract-structured-data`, `post-to-site`); the A2A loop is wired end-to-end against RockBot via the `docker-compose.yml` harness pinned to `rockylhotka/rockbot-agent:0.8.5`. The governing spec is now `docs/foragent-specification.md` **v0.2** — read it before making non-trivial changes. v0.2 pivots Foragent to an agentic model: one generalist `browser-task` capability (built natively on `Microsoft.Playwright` NuGet — no MCP sidecar, no Stagehand port — see Appendix A #16) plus narrow fast-path specialists, with RockBot's `ISkillStore` + `ILongTermMemory` as the learning substrate. The v0.1 proposal document is archived at `docs/archive/foragent-spec-v0.2-proposal.md`. Storage-state persistence, 2FA input-required flow, k8s-secrets broker, and per-tenant credential namespaces remain deferred — tracked in `docs/framework-feedback.md` step 4. Framework-level observations from each milestone are captured in `docs/framework-feedback.md`.
7+
Foragent is at **milestone 6 shipped, step 7 next**. Four capabilities are live (`browser-task`, `fetch-page-title`, `extract-structured-data`, `post-to-site`); the A2A loop is wired end-to-end against RockBot via the `docker-compose.yml` harness pinned to `rockylhotka/rockbot-agent:0.8.5`. Step 6 shipped the generalist `browser-task` planner (LLM-in-the-loop over ref-annotated aria snapshots + `aria-ref=eN` locator resolution, built on `Microsoft.Playwright` 1.59 — bumped from 1.50 for the Ai aria-snapshot mode; see Appendix A #16). Tiered chat clients are wired via `AddRockBotTieredChatClients` with one model aliased across Low/Balanced/High per spec §3.7. The governing spec is `docs/foragent-specification.md` **v0.2**. Step 7 wires `ISkillStore` + `ILongTermMemory` priming; `post-to-site` is removed from the advertised skill list once `browser-task` + the learned bsky skill cover it. Storage-state persistence, 2FA input-required flow, k8s-secrets broker, and per-tenant credential namespaces remain deferred — tracked in `docs/framework-feedback.md`. Framework-level observations from each milestone are captured in `docs/framework-feedback.md`.
88

99
## Build / test
1010

@@ -69,11 +69,13 @@ Key framework pieces Foragent uses today:
6969
- `RockBot.A2A.IAgentTaskHandler` — the single per-agent extension point. `ForagentTaskHandler` (in `Foragent.Capabilities`) implements this and dispatches on `request.Skill`.
7070
- `RockBot.A2A.Gateway.AddA2AHttpGateway` + `MapA2AHttpGateway` — the in-process HTTP surface. Published as NuGet in RockBot 0.8.4 (see `docs/framework-feedback.md`).
7171

72-
Foragent requires an LLM (for `extract-structured-data` and future capabilities). The same `IChatClient` is registered both as a singleton (capabilities inject it directly) and via `AddRockBotChatClient` (satisfies the framework's mandatory registration). Config lives under `ForagentLlm` — separate from any rockbot-side `LLM` config so the two agents can point at different models. Program.cs fails fast at startup if `ForagentLlm:Endpoint`/`ModelId`/`ApiKey` are missing.
72+
Foragent requires an LLM. Config lives under `ForagentLlm` — separate from any rockbot-side `LLM` config so the two agents can point at different models. Program.cs fails fast at startup if `ForagentLlm:Endpoint`/`ModelId`/`ApiKey` are missing. Starting step 6 the single configured model is wired via `AddRockBotTieredChatClients(low, balanced, high)` aliased to the same inner `IChatClient`; that one call registers both `IChatClient` (wrapped with `RockBotFunctionInvokingChatClient` for automatic tool invocation) and `TieredChatClientRegistry` (per spec §3.7). Don't also call `AddRockBotChatClient` — it would swap out the wrapped registration. Capabilities that want to escalate/de-escalate per request can resolve `TieredChatClientRegistry` and call `GetClient(ModelTier.Low|Balanced|High)`; none do today.
7373

7474
## Browser
7575

76-
`Foragent.Browser` wraps Playwright. `AddForagentBrowser()` in `Foragent.Agent/Program.cs` registers `PlaywrightBrowserHost` (`IHostedService` owning one shared Chromium per process) and `IBrowserSessionFactory` (hands out a fresh `IBrowserContext` per A2A task — isolation guarantee from spec §3.5). `IBrowserSession` exposes `FetchPageTitleAsync` / `CapturePageSnapshotAsync` for one-shot reads, plus `OpenPageAsync``IBrowserPage` (navigate / fill / click / wait / read) for multi-step flows like login + post. The snapshot uses Chromium's aria-snapshot (via `Locator.AriaSnapshotAsync`) and falls back to `<body>` inner text when the tree is empty. Selectors passed to `IBrowserPage` use Playwright's string-selector dialect (CSS + `role=role[name="..."]`); **regex is not accepted in string form**, use exact attribute matches. `Foragent.Browser` has `InternalsVisibleTo("Foragent.Browser.Tests")` so tests drive the real `PlaywrightBrowserSessionFactory` without promoting its implementation types to public.
76+
`Foragent.Browser` wraps Playwright. `AddForagentBrowser()` in `Foragent.Agent/Program.cs` registers `PlaywrightBrowserHost` (`IHostedService` owning one shared Chromium per process) and `IBrowserSessionFactory` (hands out a fresh `IBrowserContext` per A2A task — isolation guarantee from spec §3.5). `IBrowserSession` exposes `FetchPageTitleAsync` / `CapturePageSnapshotAsync` for one-shot reads, `OpenPageAsync` → `IBrowserPage` (navigate / fill / click / wait / read) for multi-step flows like login + post, and `OpenAgentPageAsync` → `IBrowserAgentPage` for LLM-in-the-loop planners (ref-annotated aria snapshots + `aria-ref=eN` locator resolution). The snapshot uses Chromium's aria-snapshot (via `Locator.AriaSnapshotAsync`; `Mode = AriaSnapshotMode.Ai` gets the ref-annotated form) and falls back to `<body>` inner text when the tree is empty. Selectors passed to `IBrowserPage` use Playwright's string-selector dialect (CSS + `role=role[name="..."]`); **regex is not accepted in string form**, use exact attribute matches. `Foragent.Browser` has `InternalsVisibleTo("Foragent.Browser.Tests")` so tests drive the real `PlaywrightBrowserSessionFactory` without promoting its implementation types to public.
77+
78+
`CreateSessionAsync(Func<Uri,bool> allowedHost, ...)` is the step-6 entry point for allowlist-scoped sessions. The factory installs a context-wide `RouteAsync("**/*", ...)` that aborts off-list document/subframe navigations before Playwright issues the request (spec §7.1). The no-argument overload accepts any host and stays available for specialists that enforce narrower rules elsewhere (e.g. `post-to-site` where the site id selects the host).
7779

7880
## Capabilities
7981

@@ -84,6 +86,7 @@ Foragent requires an LLM (for `extract-structured-data` and future capabilities)
8486
- `ForagentCapabilities.Skills` (static array) is the single source of truth for advertised skills — both the bus-side `AgentCard.Skills` and the HTTP gateway's `opts.Skills` read from it.
8587
- `CapabilityInput.Parse` is the shared URL + description shim used by `fetch-page-title` and `extract-structured-data`. Capabilities with different input shapes (e.g. `post-to-site` needing `site` / `credentialId` / `content`) parse their own input near the capability — see `PostToSiteInput` in `PostToSiteCapability.cs`. Don't overload `CapabilityInput` for unrelated shapes.
8688
- `post-to-site` dispatches to an `ISitePoster` keyed on `Site` (in `SitePosting/`). `BlueskySitePoster` is the only implementation today; add new sites by registering another `ISitePoster` in `AddForagentCapabilities()`. The capability never echoes exception messages from posters back to callers — they may contain credential material; operators read the full exception in logs.
89+
- `browser-task` (in `BrowserTask/`) is the generalist planner (spec §5.2). `BrowserTaskInput` parses intent + mandatory `allowedHosts` + optional `url` / `credentialId` / `maxSteps` (default 60, ceiling 150) / `maxSeconds` (default 120, ceiling 600). `BrowserTaskTools` wraps `snapshot` / `navigate` / `click` / `type` / `wait_for` / `done` / `fail` as `AIFunction`s via `AIFunctionFactory.Create` and passes them in `ChatOptions.Tools`; the RockBot-wrapped function-invoking `IChatClient` runs the full model ↔ tool loop inside one `GetResponseAsync` call. Budget is enforced tool-side (each tool checks `BrowserTaskState.BudgetExhausted`) because Microsoft.Extensions.AI does not surface per-request iteration caps through `ChatOptions`; wall-clock is a linked `CancellationTokenSource`. **Never log tool arguments verbatim** — `type` carries user-supplied values that may be sensitive (log length only). Refs from a snapshot are valid only until the next mutating call; the system prompt and tool descriptions both state this, but don't code anything that assumes cross-snapshot ref stability.
8790

8891
## Credentials
8992

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<CentralPackageFloatingVersionsEnabled>true</CentralPackageFloatingVersionsEnabled>
55
</PropertyGroup>
66
<ItemGroup>
7-
<PackageVersion Include="Microsoft.Playwright" Version="1.50.0" />
7+
<PackageVersion Include="Microsoft.Playwright" Version="1.59.0" />
88
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.*" />
99
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.*" />
1010
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.*" />

deploy/rockbot-seed/agent-trust.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"agentId": "Foragent",
44
"level": 4,
5-
"approvedSkills": ["fetch-page-title", "extract-structured-data", "post-to-site"],
5+
"approvedSkills": ["browser-task", "fetch-page-title", "extract-structured-data", "post-to-site"],
66
"firstSeen": "2026-04-21T00:00:00+00:00",
77
"lastInteraction": "2026-04-21T00:00:00+00:00",
88
"interactionCount": 0

deploy/rockbot-seed/well-known-agents.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
"authHeaderName": "X-Api-Key",
99
"authHeaderValueBase64": "cm9ja2JvdC1jYWxscy1mb3JhZ2VudA==",
1010
"skills": [
11+
{
12+
"id": "browser-task",
13+
"name": "Browser Task (generalist)",
14+
"description": "Drive a browser with an LLM-in-the-loop planner to accomplish a free-form intent. Input JSON {\"intent\":\"...\",\"allowedHosts\":[\"host\",\"*.host\",\"*\"],\"url\":\"optional start\",\"credentialId\":\"optional\",\"maxSteps\":60,\"maxSeconds\":120}. allowedHosts is required and empty rejects. Returns a structured JSON result with status (done/failed/incomplete), summary, optional result, step count, and navigations."
15+
},
1116
{
1217
"id": "fetch-page-title",
1318
"name": "Fetch Page Title",

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ services:
6161
RabbitMq__VirtualHost: /
6262
Gateway__AgentName: Foragent
6363
Gateway__InternalAgentName: Foragent
64-
Gateway__Description: "Browser agent — fetch-page-title, extract-structured-data, post-to-site"
64+
Gateway__Description: "Browser agent — browser-task (generalist), fetch-page-title, extract-structured-data, post-to-site"
6565
# RockBot will call Foragent with header X-Api-Key: rockbot-calls-foragent
6666
ApiKeys__rockbot-calls-foragent__AgentId: RockBot
6767
ApiKeys__rockbot-calls-foragent__DisplayName: RockBot

docs/capabilities.md

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,84 @@
33
Foragent exposes browser operations as discrete A2A capabilities. Callers
44
invoke capabilities by name; Foragent handles the browser mechanics.
55

6-
## Planned initial capability set
6+
## Advertised capabilities (v0.2)
77

8-
- [ ] `fetch-page-content` — Navigate to a URL and return the page content
9-
- [ ] `extract-structured-data` — Extract structured data from a page using
10-
an LLM-assisted schema
11-
- [ ] `fill-form` — Fill and optionally submit an HTML form
12-
- [ ] `post-to-site` — Perform a multi-step posting action on a target site
13-
- [ ] `monitor-page` — Poll a page for a condition and notify when met
8+
- `browser-task`**generalist**, spec §5.2. LLM-in-the-loop planner that
9+
drives a real browser to accomplish a free-form intent. Shipped in
10+
step 6.
11+
- `fetch-page-title` — specialist. Inherited from step 1/2.
12+
- `extract-structured-data` — specialist. Inherited from step 3.
13+
- `post-to-site` — specialist, credential-using. Inherited from step 4.
14+
Scheduled for removal from the advertised list once step 7 lands
15+
(`browser-task` + learned bsky skill subsume it).
16+
17+
## `browser-task` input shape
18+
19+
JSON in the first text part, or field-by-field metadata:
20+
21+
```json
22+
{
23+
"intent": "free-form description of what to accomplish",
24+
"allowedHosts": ["bsky.app", "*.example.com", "*"],
25+
"url": "optional absolute http(s) starting URL",
26+
"credentialId": "optional broker reference",
27+
"maxSteps": 60,
28+
"maxSeconds": 120
29+
}
30+
```
31+
32+
- `intent` — required. Free-form.
33+
- `allowedHosts` — required, non-empty (spec §7.1). An empty list rejects.
34+
Supports exact hosts, `*.domain` subdomain wildcards, and `*` for
35+
unrestricted. Off-list navigations are aborted inside the browser
36+
context before Playwright issues the request.
37+
- `url` — optional. If provided, must match the allowlist.
38+
- `credentialId` — optional. Resolved but not exposed to the planner in
39+
step 6; reserved for a typed login tool in a later step.
40+
- `maxSteps` — default 60, ceiling 150. Enforced tool-side via
41+
`BrowserTaskState.BudgetExhausted`; once exceeded, tools return a
42+
"call done or fail" message and refuse further work.
43+
- `maxSeconds` — default 120, ceiling 600. Enforced via a linked
44+
`CancellationTokenSource`.
45+
46+
## `browser-task` output shape
47+
48+
A JSON object in a single text part:
49+
50+
```json
51+
{
52+
"status": "done" | "failed" | "incomplete",
53+
"summary": "one-sentence human-readable result",
54+
"result": "optional structured result text (e.g. extracted value)",
55+
"steps": 7,
56+
"navigations": ["https://host/path", "..."]
57+
}
58+
```
59+
60+
`incomplete` means the budget was exhausted before `done`/`fail` was
61+
called.
62+
63+
## `browser-task` tool surface
64+
65+
Exposed to the planner via `[AIFunction]` wrappers over `IChatClient`
66+
(spec Appendix A #16 — no MCP sidecar). Refs are Playwright aria-ref ids
67+
and are valid only within the snapshot they came from.
68+
69+
- `snapshot()` — ref-annotated aria tree of the current page.
70+
- `navigate(url)` — load a URL; host must be on the allowlist.
71+
- `click(ref)` — click by ref.
72+
- `type(ref, text)` — fill by ref.
73+
- `wait_for(ref, timeoutSeconds?)` — wait for visibility.
74+
- `done(summary, result?)` — mark complete.
75+
- `fail(reason)` — mark failed.
1476

1577
## Design principles
1678

17-
- Capabilities operate at the task level, not at the DOM-operation level
18-
- Each capability invocation gets an isolated browser context
79+
- Capabilities operate at the task level, not at the DOM-operation level.
80+
- Each capability invocation gets an isolated `BrowserContext` (spec §3.5).
81+
- Per-task host allowlists are mandatory (spec §7.1).
1982
- Credential references are passed by ID; values are resolved inside
20-
Foragent and never cross A2A boundaries
83+
Foragent and never cross A2A boundaries (spec §6.1).
84+
- Prohibited capabilities — account creation, financial transactions,
85+
modifying security permissions — are out of scope regardless of
86+
implementation ease (spec §7.3).

docs/framework-feedback.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,88 @@ in `.env`.
254254
discovery) and `GatewayOptions.Skills` (HTTP agent-card endpoint) are independent. Our
255255
Program.cs populates both from a single `ForagentCapabilities.Skills` array — a workaround,
256256
not a fix. The framework should treat one as authoritative and derive the other.
257+
258+
## Step 6 — baseline `browser-task` generalist
259+
260+
### Framework observations
261+
262+
- **`AddRockBotTieredChatClients` obviates `AddRockBotChatClient` but this
263+
is undocumented.** Calling `AddRockBotTieredChatClients(low, balanced,
264+
high)` registers an `IChatClient` singleton whose factory already wraps
265+
the inner client with `RockBotFunctionInvokingChatClient`, plus a
266+
`TieredChatClientRegistry` singleton. Callers who previously used
267+
`AddRockBotChatClient(client)` don't need to call both — but that's
268+
not spelled out anywhere. If both are called, the second registration
269+
silently wins (standard MEDI behavior), which can swap the wrapped
270+
client for an unwrapped one depending on order. Docs gap; candidate
271+
framework fix is either a guard throw or collapsing both methods into
272+
one overload shape.
273+
274+
- **No per-request iteration cap surface on the function-invoking chat
275+
client.** `FunctionInvokingChatClient.MaximumIterationsPerRequest` is
276+
an *instance* property, and the wrapped client is built inside
277+
`AddRockBotTieredChatClients` — the caller has no hook to set it per
278+
`GetResponseAsync` invocation. `ChatOptions.AdditionalProperties`
279+
lookup keys are not honored. `ModelBehavior.MaxToolIterationsOverride`
280+
exists on the RockBot side but routes through YAML behavior config,
281+
not per-call. Foragent enforces its step budget tool-side (each tool
282+
checks `BrowserTaskState.BudgetExhausted`); wall-clock cancellation
283+
is the real safety net. Framework candidate: either honor a standard
284+
`ChatOptions.AdditionalProperties["MaximumIterationsPerRequest"]`
285+
convention or expose the FICC instance via DI so consumers can
286+
configure it.
287+
288+
- **`Microsoft.Playwright` 1.50 (pinned since step 2) does not expose
289+
the Ai aria-snapshot mode.** Step 6 requires ref-annotated snapshots
290+
(`[ref=eN]` + `aria-ref=eN` locator resolution). That gating moved
291+
from a boolean `Ref` option to `Mode = AriaSnapshotMode.Ai` sometime
292+
between 1.52 and the current 1.59 C# bindings. Foragent bumped the
293+
pin to 1.59.0; container base image
294+
(`mcr.microsoft.com/playwright/dotnet:v1.50.0-noble`) will need the
295+
matching bump in the first release that ships browser-task. Not a
296+
framework-issue per se, but relevant to RockBot's "v1 Foragent" story
297+
and to anyone using the framework + Playwright together.
298+
299+
- **Aria-ref lifetime is a contract the planner must respect.** Refs are
300+
valid only within the snapshot they came from. The tool surface
301+
documents this in the `snapshot` description; if the framework ever
302+
ships a "browser task runner" helper of its own (candidate
303+
`RockBot.Browser.Planner`?), it should bake the "re-snapshot after
304+
mutation" rule into a first-class contract rather than leaving it to
305+
prompt text.
306+
307+
- **`AIFunctionFactory.Create(Delegate, name:, description:, …)`
308+
descriptions only surface the method-level `[Description]`.** Parameter
309+
descriptions must be on parameters via `[Description]` — easy to miss
310+
without the reminder. Worked as expected; noting for anyone building
311+
similar tool surfaces.
312+
313+
- **RockBot's `RockBotFunctionInvokingChatClient` auto-invokes tools end
314+
to end in a single `GetResponseAsync` call.** This is exactly what the
315+
planner wants; no custom loop needed. One quirk: the FICC keeps
316+
iterating as long as the model emits tool calls, with no public
317+
step cap (see above). Combined with aria-ref lifetimes, a model that
318+
thrashes on stale refs can burn budget fast. Step 7's learning
319+
substrate is the intended mitigation.
320+
321+
### Unaided floor measurement (2026-04-22)
322+
323+
First end-to-end benchmark against the operator's Azure AI Foundry
324+
Balanced model (no learned skills, no priming — the "unaided" floor the
325+
spec §9.1 step 6 calls for):
326+
327+
| Scenario | Result | Wall-clock |
328+
|---|---|---|
329+
| Click-through (home → link → read destination value) | ✅ done | 5 s |
330+
| Form submit (fill name + textarea → submit → read confirmation) | ✅ done | 8 s |
331+
| Multi-page nav (index → intro → chapter-2 → read bolded answer) | ✅ done | 7 s |
332+
333+
3 / 3 passed on first attempt. Establishes the baseline Foragent must
334+
not regress against once step 7 adds priming. Re-run this set whenever
335+
the planner prompt, tool surface, or model pin changes.
336+
337+
### Not yet exercised
338+
339+
- **`TieredChatClientRegistry.GetClient(ModelTier.Low/High)` is wired
340+
but no capability resolves it yet.** All three tiers currently alias
341+
to the same model. Tier-aware capability code lands as models diverge.

0 commit comments

Comments
 (0)