From ebed286d4e0a37c607dc6c226b431569bcf21db8 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Tue, 1 Sep 2026 21:51:03 +0300 Subject: [PATCH 1/9] Give Fisherman's run one Haul and thin the finish tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One `Haul` now owns "the requests this run made": it captures the start mark once, after `refreshAuth()`, and both the stuck-detector and the tools read their run views from it. The failure predicate lives once, as `isFailedRequest` in the data tier, and `finish` delegates its verification to `verifyFinish` instead of judging inline. `src/utils/request-map.ts` is gone — `Haul.byId()` replaces it, so `src/utils/` no longer depends on `src/api/`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B46vQvvc47LJeCmqQxAhme --- src/ai/fisherman-tools.ts | 71 ++++++++++++++++-------------- src/ai/fisherman.ts | 14 +++--- src/api/request-store.ts | 34 ++++++++++++++ src/utils/request-map.ts | 19 -------- tests/unit/fisherman-tools.test.ts | 29 +++++++----- tests/unit/request-store.test.ts | 31 ++++++++++++- 6 files changed, 126 insertions(+), 72 deletions(-) delete mode 100644 src/utils/request-map.ts diff --git a/src/ai/fisherman-tools.ts b/src/ai/fisherman-tools.ts index 19cc0969..a23fd7c2 100644 --- a/src/ai/fisherman-tools.ts +++ b/src/ai/fisherman-tools.ts @@ -3,24 +3,20 @@ import dedent from 'dedent'; import { z } from 'zod'; import type { ApiClient } from '../api/api-client.ts'; import type { RequestResult } from '../api/request-result.ts'; -import type { RequestStore } from '../api/request-store.ts'; +import type { Haul, RequestStore } from '../api/request-store.ts'; import { extractEndpointDefinition } from '../api/spec-reader.ts'; import { tag } from '../utils/logger.ts'; -import { RequestMap } from '../utils/request-map.ts'; import { isDynamicSegment } from '../utils/url-matcher.ts'; -export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) { +export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: Haul, opts: { spec?: any; baseEndpoint?: string }) { let finished = false; let result: FishermanResult | null = null; - const ledgerStart = requestStore.getMadeRequests().length; - const runRequests = () => requestStore.getMadeRequests().slice(ledgerStart); - const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400); - const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false); + const getResult = () => result ?? synthesizeResult(haul, false); const isFinished = () => finished; const finishFromText = (text?: string) => { finished = true; - const synthesized = synthesizeResult(runRequests(), successfulWrites(), true); + const synthesized = synthesizeResult(haul, true); if (text && synthesized.success) synthesized.summary = text; result = synthesized; }; @@ -160,32 +156,12 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request .describe('List of items that could not be created'), }), execute: async ({ summary, created, failed }) => { - const writes = successfulWrites(); - if (writes.length === 0) { - tag('warning').log('Fisherman: finish rejected — no successful write request in this run'); - return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' }; - } - - const createdRequests = new RequestMap(writes); - - const verified: FishermanResult['created'] = []; - for (const item of created) { - if (item.id === undefined) { - verified.push(item); - continue; - } - const request = createdRequests.get(item.id); - if (!request) { - tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`); - continue; - } - verified.push({ ...item, request: request.toEndpoint() }); - } - if (verified.length === 0) verified.push(...writes.map(toCreatedItem)); + const { result: verified, error } = verifyFinish(haul, { summary, created, failed }); + if (!verified) return { finished: false, error }; tag('success').log(`Fisherman done: ${summary}`); finished = true; - result = { success: true, summary, created: verified, failed: failed || [] }; + result = verified; return { finished: true }; }, }), @@ -207,8 +183,37 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request return { tools, getResult, isFinished, finishFromText }; } -function synthesizeResult(made: RequestResult[], writes: RequestResult[], declaredDone: boolean): FishermanResult { - const failures = made.filter((r) => r.status >= 400 || r.error); +export function verifyFinish(haul: Haul, input: { summary: string; created: FishermanResult['created']; failed?: FishermanResult['failed'] }): { result: FishermanResult | null; error?: string } { + const writes = haul.successfulWrites(); + if (writes.length === 0) { + tag('warning').log('Fisherman: finish rejected — no successful write request in this run'); + return { result: null, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' }; + } + + const createdRequests = haul.byId(); + + const verified: FishermanResult['created'] = []; + for (const item of input.created) { + if (item.id === undefined) { + verified.push(item); + continue; + } + const request = createdRequests.get(String(item.id)); + if (!request) { + tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`); + continue; + } + verified.push({ ...item, request: request.toEndpoint() }); + } + if (verified.length === 0) verified.push(...writes.map(toCreatedItem)); + + return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } }; +} + +function synthesizeResult(haul: Haul, declaredDone: boolean): FishermanResult { + const made = haul.requests(); + const writes = haul.successfulWrites(); + const failures = haul.failed(); let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`; const lastFailure = failures[failures.length - 1]; if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`; diff --git a/src/ai/fisherman.ts b/src/ai/fisherman.ts index c6dd6e4c..d640e665 100644 --- a/src/ai/fisherman.ts +++ b/src/ai/fisherman.ts @@ -1,6 +1,6 @@ import dedent from 'dedent'; import type { ApiClient } from '../api/api-client.ts'; -import type { RequestStore } from '../api/request-store.ts'; +import { Haul, type RequestStore, isFailedRequest } from '../api/request-store.ts'; import { listAllEndpoints } from '../api/spec-reader.ts'; import { createDebug, tag } from '../utils/logger.ts'; @@ -79,11 +79,11 @@ export class Fisherman implements Agent { await this.refreshAuth(); debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`); - const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, { + const haul = new Haul(this.requestStore); + const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, { spec: this.spec, baseEndpoint: this.baseEndpoint, }); - const ledgerStart = this.requestStore.getMadeRequests().length; const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman'); conversation.addUserText(this.buildTaskPrompt(instructions)); @@ -109,7 +109,7 @@ export class Fisherman implements Agent { return; } - if (this.isStuckOnEndpoint(ledgerStart)) { + if (this.isStuckOnEndpoint(haul)) { tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping'); stop(); return; @@ -228,12 +228,12 @@ export class Fisherman implements Agent { `; } - private isStuckOnEndpoint(ledgerStart: number): boolean { - const made = this.requestStore.getMadeRequests().slice(ledgerStart); + private isStuckOnEndpoint(haul: Haul): boolean { + const made = haul.requests(); if (made.length < REPEATED_FAILURE_LIMIT) return false; const recent = made.slice(-REPEATED_FAILURE_LIMIT); const first = recent[0]; - return recent.every((r) => (r.status >= 400 || r.error) && r.method === first.method && r.path === first.path); + return recent.every((r) => isFailedRequest(r) && r.method === first.method && r.path === first.path); } private buildTaskPrompt(instructions: string): string { diff --git a/src/api/request-store.ts b/src/api/request-store.ts index 9217921c..4ffbc479 100644 --- a/src/api/request-store.ts +++ b/src/api/request-store.ts @@ -191,6 +191,40 @@ export class RequestStore { } } +export class Haul { + private start: number; + + constructor(private store: RequestStore) { + this.start = store.getMadeRequests().length; + } + + requests(): RequestResult[] { + return this.store.getMadeRequests().slice(this.start); + } + + failed(): RequestResult[] { + return this.requests().filter(isFailedRequest); + } + + successfulWrites(): RequestResult[] { + return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400); + } + + byId(): Map { + const map = new Map(); + for (const request of this.successfulWrites()) { + const { id } = request.extractIdAndTitle(); + if (id === undefined) continue; + map.set(String(id), request); + } + return map; + } +} + +export function isFailedRequest(request: RequestResult): boolean { + return request.status >= 400 || Boolean(request.error); +} + function normalizePathPattern(urlPath: string): string { return urlPath .split('/') diff --git a/src/utils/request-map.ts b/src/utils/request-map.ts deleted file mode 100644 index 0c320392..00000000 --- a/src/utils/request-map.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RequestResult } from '../api/request-result.ts'; - -export class RequestMap { - private requests = new Map(); - - constructor(requests: RequestResult[] = []) { - for (const request of requests) this.add(request); - } - - add(request: RequestResult): void { - const { id } = request.extractIdAndTitle(); - if (id === undefined) return; - this.requests.set(String(id), request); - } - - get(id: string | number): RequestResult | undefined { - return this.requests.get(String(id)); - } -} diff --git a/tests/unit/fisherman-tools.test.ts b/tests/unit/fisherman-tools.test.ts index 752a572f..e2c94e48 100644 --- a/tests/unit/fisherman-tools.test.ts +++ b/tests/unit/fisherman-tools.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'bun:test'; import { createFishermanTools } from '../../src/ai/fisherman-tools.ts'; +import { Haul } from '../../src/api/request-store.ts'; describe('Fisherman tools', () => { it('does not present a rejected capture as a request example', async () => { const captured = { method: 'POST', path: '/plans', status: 400, requestBody: { plan: 'wrong' } }; - const { tools } = createFishermanTools({} as any, store(captured), {}); + const { tools } = fishermanTools({} as any, store(captured), {}); const result: any = await tools.getEndpointSpec.execute({ method: 'POST', path: '/plans' }, {} as any); @@ -16,7 +17,7 @@ describe('Fisherman tools', () => { it('prefers the specification when the captured request was rejected', async () => { const captured = { method: 'POST', path: '/plans', status: 422, requestBody: { plan: 'wrong' } }; const spec = { paths: { '/plans': { post: { requestBody: { required: true } } } } }; - const { tools } = createFishermanTools({} as any, store(captured), { spec }); + const { tools } = fishermanTools({} as any, store(captured), { spec }); const result: any = await tools.getEndpointSpec.execute({ method: 'POST', path: '/plans' }, {} as any); @@ -27,7 +28,7 @@ describe('Fisherman tools', () => { it('keeps a successful captured request as a usable example', async () => { const captured = { method: 'POST', path: '/plans', status: 201, requestBody: { title: 'Plan' } }; - const { tools } = createFishermanTools({} as any, store(captured), {}); + const { tools } = fishermanTools({} as any, store(captured), {}); const result: any = await tools.getEndpointSpec.execute({ method: 'POST', path: '/plans' }, {} as any); @@ -48,7 +49,7 @@ describe('Fisherman tools', () => { const apiClient = { request: async () => ({ status, statusText: String(status), rawResponseBody: '{}', responseBody: null }), }; - const { tools } = createFishermanTools(apiClient as any, store(), {}); + const { tools } = fishermanTools(apiClient as any, store(), {}); const result: any = await tools.request.execute({ method: 'POST', path: '/plans' }, {} as any); @@ -60,7 +61,7 @@ describe('Fisherman tools', () => { const apiClient = { request: async () => ({ status: 201, statusText: 'Created', rawResponseBody: '', responseBody: { id: 7, status: 'draft' } }), }; - const { tools } = createFishermanTools(apiClient as any, store(), {}); + const { tools } = fishermanTools(apiClient as any, store(), {}); const result: any = await tools.request.execute({ method: 'POST', path: '/items' }, {} as any); @@ -71,7 +72,7 @@ describe('Fisherman tools', () => { describe('ledger-derived results', () => { it('rejects finish when no successful write was made in this run', async () => { - const { tools, isFinished } = createFishermanTools({} as any, store(), {}); + const { tools, isFinished } = fishermanTools({} as any, store(), {}); const result: any = await tools.finish.execute({ summary: 'done', created: [{ type: 'suite', id: '1' }] }, {} as any); @@ -82,7 +83,7 @@ describe('ledger-derived results', () => { it('ignores writes made before this run started', async () => { const made = [madeWrite('POST', '/api/suites', 201, { id: 's1' })]; - const { tools, isFinished } = createFishermanTools({} as any, store(undefined, made), {}); + const { tools, isFinished } = fishermanTools({} as any, store(undefined, made), {}); const result: any = await tools.finish.execute({ summary: 'done', created: [{ type: 'suite', id: 's1' }] }, {} as any); @@ -92,7 +93,7 @@ describe('ledger-derived results', () => { it('drops created items whose id no write response returned, keeps verified ones with their request', async () => { const made: any[] = []; - const { tools, getResult } = createFishermanTools({} as any, store(undefined, made), {}); + const { tools, getResult } = fishermanTools({} as any, store(undefined, made), {}); made.push(madeWrite('POST', '/api/suites', 201, { id: 's1', title: 'Suite A' })); await tools.finish.execute( @@ -113,7 +114,7 @@ describe('ledger-derived results', () => { it('reports failure when the loop ends without finish, even after a successful write', async () => { const made: any[] = []; - const { getResult } = createFishermanTools({} as any, store(undefined, made), {}); + const { getResult } = fishermanTools({} as any, store(undefined, made), {}); made.push(madeWrite('POST', '/api/suites', 201, { id: 's1', title: 'Suite A' })); made.push(madeWrite('POST', '/api/tests', 400)); @@ -126,7 +127,7 @@ describe('ledger-derived results', () => { it('reports failure with a reason when the loop ends with no successful writes', async () => { const made: any[] = []; - const { getResult } = createFishermanTools({} as any, store(undefined, made), {}); + const { getResult } = fishermanTools({} as any, store(undefined, made), {}); made.push(madeWrite('POST', '/api/tests', 400)); const result = getResult(); @@ -136,7 +137,7 @@ describe('ledger-derived results', () => { it('treats a text-only turn as finish, using the text as summary when writes succeeded', () => { const made: any[] = []; - const { finishFromText, getResult, isFinished } = createFishermanTools({} as any, store(undefined, made), {}); + const { finishFromText, getResult, isFinished } = fishermanTools({} as any, store(undefined, made), {}); made.push(madeWrite('POST', '/api/suites', 201, { id: 's1' })); finishFromText('Created the suite'); @@ -150,7 +151,7 @@ describe('ledger-derived results', () => { it('keeps the honest failure summary when a text-only turn ends a run with no successful writes', () => { const made: any[] = []; - const { finishFromText, getResult } = createFishermanTools({} as any, store(undefined, made), {}); + const { finishFromText, getResult } = fishermanTools({} as any, store(undefined, made), {}); made.push(madeWrite('POST', '/api/tests', 400)); finishFromText('All done successfully'); @@ -161,6 +162,10 @@ describe('ledger-derived results', () => { }); }); +function fishermanTools(apiClient: any, requestStore: any, opts: any): any { + return createFishermanTools(apiClient, requestStore, new Haul(requestStore), opts); +} + function store(captured?: any, made: any[] = []): any { return { findCapturedRequest: () => captured, diff --git a/tests/unit/request-store.test.ts b/tests/unit/request-store.test.ts index bfc27802..190970f5 100644 --- a/tests/unit/request-store.test.ts +++ b/tests/unit/request-store.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { RequestResult } from '../../src/api/request-result.js'; -import { RequestStore } from '../../src/api/request-store.js'; +import { Haul, RequestStore } from '../../src/api/request-store.js'; let counter = 0; function makeRequest(method: string, path: string, status: number, id?: string, headers: Record = {}): RequestResult { @@ -317,3 +317,32 @@ describe('extractAuthHeaders session gating', () => { expect(store.extractAuthHeaders()).toEqual({}); }); }); + +describe('Haul', () => { + let outputDir: string; + + beforeEach(() => { + outputDir = mkdtempSync(join(tmpdir(), 'reqstore-')); + }); + + afterEach(() => { + if (existsSync(outputDir)) rmSync(outputDir, { recursive: true, force: true }); + }); + + it('sees only the requests made after it was created', () => { + const store = new RequestStore(outputDir); + store.addMadeRequest(makeRequest('GET', '/api/suites', 200)); + store.addMadeRequest(makeRequest('POST', '/api/suites', 201)); + + const haul = new Haul(store); + store.addMadeRequest(makeRequest('POST', '/api/tests', 422)); + const created = makeRequest('POST', '/api/tests', 201); + created.rawResponseBodyValue = JSON.stringify({ id: 42, title: 'Test A' }); + store.addMadeRequest(created); + + expect(haul.requests()).toHaveLength(2); + expect(haul.failed()).toHaveLength(1); + expect(haul.successfulWrites()).toHaveLength(1); + expect(haul.byId().get('42')?.path).toBe('/api/tests'); + }); +}); From 77ed9c4007c2babee4f64b0a5248b4cfb8703093 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:14:48 +0300 Subject: [PATCH 2/9] Rename Haul to RequestHaul and move it under the Fisherman agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class is used by Fisherman alone, so it belongs beside the agent rather than in the shared request data tier: `src/ai/fisherman/request-haul.ts`, matching how other agents keep their utility classes (`ai/researcher/*`). `isFailedRequest` stays in `request-store.ts` — it is still the one shared failure predicate, called by both `RequestHaul.failed()` and Fisherman's `isStuckOnEndpoint`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYfsBh3TXThD5XLxa23X5d --- src/ai/fisherman-tools.ts | 9 +++++---- src/ai/fisherman.ts | 7 ++++--- src/ai/fisherman/request-haul.ts | 32 ++++++++++++++++++++++++++++++ src/api/request-store.ts | 30 ---------------------------- tests/unit/fisherman-tools.test.ts | 4 ++-- tests/unit/request-store.test.ts | 7 ++++--- 6 files changed, 47 insertions(+), 42 deletions(-) create mode 100644 src/ai/fisherman/request-haul.ts diff --git a/src/ai/fisherman-tools.ts b/src/ai/fisherman-tools.ts index a23fd7c2..912af3a0 100644 --- a/src/ai/fisherman-tools.ts +++ b/src/ai/fisherman-tools.ts @@ -3,12 +3,13 @@ import dedent from 'dedent'; import { z } from 'zod'; import type { ApiClient } from '../api/api-client.ts'; import type { RequestResult } from '../api/request-result.ts'; -import type { Haul, RequestStore } from '../api/request-store.ts'; +import type { RequestStore } from '../api/request-store.ts'; import { extractEndpointDefinition } from '../api/spec-reader.ts'; import { tag } from '../utils/logger.ts'; import { isDynamicSegment } from '../utils/url-matcher.ts'; +import type { RequestHaul } from './fisherman/request-haul.ts'; -export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: Haul, opts: { spec?: any; baseEndpoint?: string }) { +export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: { spec?: any; baseEndpoint?: string }) { let finished = false; let result: FishermanResult | null = null; @@ -183,7 +184,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request return { tools, getResult, isFinished, finishFromText }; } -export function verifyFinish(haul: Haul, input: { summary: string; created: FishermanResult['created']; failed?: FishermanResult['failed'] }): { result: FishermanResult | null; error?: string } { +export function verifyFinish(haul: RequestHaul, input: { summary: string; created: FishermanResult['created']; failed?: FishermanResult['failed'] }): { result: FishermanResult | null; error?: string } { const writes = haul.successfulWrites(); if (writes.length === 0) { tag('warning').log('Fisherman: finish rejected — no successful write request in this run'); @@ -210,7 +211,7 @@ export function verifyFinish(haul: Haul, input: { summary: string; created: Fish return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } }; } -function synthesizeResult(haul: Haul, declaredDone: boolean): FishermanResult { +function synthesizeResult(haul: RequestHaul, declaredDone: boolean): FishermanResult { const made = haul.requests(); const writes = haul.successfulWrites(); const failures = haul.failed(); diff --git a/src/ai/fisherman.ts b/src/ai/fisherman.ts index d640e665..4d6f9d6b 100644 --- a/src/ai/fisherman.ts +++ b/src/ai/fisherman.ts @@ -1,6 +1,6 @@ import dedent from 'dedent'; import type { ApiClient } from '../api/api-client.ts'; -import { Haul, type RequestStore, isFailedRequest } from '../api/request-store.ts'; +import { type RequestStore, isFailedRequest } from '../api/request-store.ts'; import { listAllEndpoints } from '../api/spec-reader.ts'; import { createDebug, tag } from '../utils/logger.ts'; @@ -8,6 +8,7 @@ const debugLog = createDebug('explorbot:fisherman'); import { loop } from '../utils/loop.ts'; import type { Agent } from './agent.ts'; import { type FishermanResult, createFishermanTools } from './fisherman-tools.ts'; +import { RequestHaul } from './fisherman/request-haul.ts'; import type { Provider } from './provider.ts'; import { dataProtectionRules } from './rules.ts'; @@ -79,7 +80,7 @@ export class Fisherman implements Agent { await this.refreshAuth(); debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`); - const haul = new Haul(this.requestStore); + const haul = new RequestHaul(this.requestStore); const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, { spec: this.spec, baseEndpoint: this.baseEndpoint, @@ -228,7 +229,7 @@ export class Fisherman implements Agent { `; } - private isStuckOnEndpoint(haul: Haul): boolean { + private isStuckOnEndpoint(haul: RequestHaul): boolean { const made = haul.requests(); if (made.length < REPEATED_FAILURE_LIMIT) return false; const recent = made.slice(-REPEATED_FAILURE_LIMIT); diff --git a/src/ai/fisherman/request-haul.ts b/src/ai/fisherman/request-haul.ts new file mode 100644 index 00000000..c3213fc4 --- /dev/null +++ b/src/ai/fisherman/request-haul.ts @@ -0,0 +1,32 @@ +import type { RequestResult } from '../../api/request-result.ts'; +import { type RequestStore, isFailedRequest } from '../../api/request-store.ts'; + +export class RequestHaul { + private start: number; + + constructor(private store: RequestStore) { + this.start = store.getMadeRequests().length; + } + + requests(): RequestResult[] { + return this.store.getMadeRequests().slice(this.start); + } + + failed(): RequestResult[] { + return this.requests().filter(isFailedRequest); + } + + successfulWrites(): RequestResult[] { + return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400); + } + + byId(): Map { + const map = new Map(); + for (const request of this.successfulWrites()) { + const { id } = request.extractIdAndTitle(); + if (id === undefined) continue; + map.set(String(id), request); + } + return map; + } +} diff --git a/src/api/request-store.ts b/src/api/request-store.ts index 4ffbc479..e5cf8903 100644 --- a/src/api/request-store.ts +++ b/src/api/request-store.ts @@ -191,36 +191,6 @@ export class RequestStore { } } -export class Haul { - private start: number; - - constructor(private store: RequestStore) { - this.start = store.getMadeRequests().length; - } - - requests(): RequestResult[] { - return this.store.getMadeRequests().slice(this.start); - } - - failed(): RequestResult[] { - return this.requests().filter(isFailedRequest); - } - - successfulWrites(): RequestResult[] { - return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400); - } - - byId(): Map { - const map = new Map(); - for (const request of this.successfulWrites()) { - const { id } = request.extractIdAndTitle(); - if (id === undefined) continue; - map.set(String(id), request); - } - return map; - } -} - export function isFailedRequest(request: RequestResult): boolean { return request.status >= 400 || Boolean(request.error); } diff --git a/tests/unit/fisherman-tools.test.ts b/tests/unit/fisherman-tools.test.ts index e2c94e48..308755a9 100644 --- a/tests/unit/fisherman-tools.test.ts +++ b/tests/unit/fisherman-tools.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { createFishermanTools } from '../../src/ai/fisherman-tools.ts'; -import { Haul } from '../../src/api/request-store.ts'; +import { RequestHaul } from '../../src/ai/fisherman/request-haul.ts'; describe('Fisherman tools', () => { it('does not present a rejected capture as a request example', async () => { @@ -163,7 +163,7 @@ describe('ledger-derived results', () => { }); function fishermanTools(apiClient: any, requestStore: any, opts: any): any { - return createFishermanTools(apiClient, requestStore, new Haul(requestStore), opts); + return createFishermanTools(apiClient, requestStore, new RequestHaul(requestStore), opts); } function store(captured?: any, made: any[] = []): any { diff --git a/tests/unit/request-store.test.ts b/tests/unit/request-store.test.ts index 190970f5..e92f95ae 100644 --- a/tests/unit/request-store.test.ts +++ b/tests/unit/request-store.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { RequestHaul } from '../../src/ai/fisherman/request-haul.js'; import { RequestResult } from '../../src/api/request-result.js'; -import { Haul, RequestStore } from '../../src/api/request-store.js'; +import { RequestStore } from '../../src/api/request-store.js'; let counter = 0; function makeRequest(method: string, path: string, status: number, id?: string, headers: Record = {}): RequestResult { @@ -318,7 +319,7 @@ describe('extractAuthHeaders session gating', () => { }); }); -describe('Haul', () => { +describe('RequestHaul', () => { let outputDir: string; beforeEach(() => { @@ -334,7 +335,7 @@ describe('Haul', () => { store.addMadeRequest(makeRequest('GET', '/api/suites', 200)); store.addMadeRequest(makeRequest('POST', '/api/suites', 201)); - const haul = new Haul(store); + const haul = new RequestHaul(store); store.addMadeRequest(makeRequest('POST', '/api/tests', 422)); const created = makeRequest('POST', '/api/tests', 201); created.rawResponseBodyValue = JSON.stringify({ id: 42, title: 'Test A' }); From 96ef4468c2dd29195549da2932331d7042b077aa Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:35:45 +0300 Subject: [PATCH 3/9] Reuse generalizeUrl for API endpoint path deduplication instead of a second walk Co-Authored-By: Claude --- src/api/request-store.ts | 15 ++++----------- src/utils/url-matcher.ts | 4 ++-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/api/request-store.ts b/src/api/request-store.ts index e5cf8903..b9657e32 100644 --- a/src/api/request-store.ts +++ b/src/api/request-store.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync } from 'node:fs'; import path from 'node:path'; -import { isDynamicSegment } from '../utils/url-matcher.ts'; +import { generalizeUrl, isDynamicSegment } from '../utils/url-matcher.ts'; import { RequestResult } from './request-result.ts'; const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token']; @@ -87,7 +87,7 @@ export class RequestStore { const lines: string[] = []; for (const req of requests) { - const key = `${req.method} ${normalizePathPattern(req.path)}`; + const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}`; if (seen.has(key)) continue; seen.add(key); lines.push(key); @@ -113,14 +113,14 @@ export class RequestStore { findCapturedRequest(method: string, searchPath: string): RequestResult | undefined { const upper = method.toUpperCase(); - const search = normalizePathPattern(searchPath).split('/').filter(Boolean); + const search = generalizeUrl(searchPath, () => '{id}').split('/').filter(Boolean); let best: RequestResult | undefined; let bestScore = -1; for (const req of this.capturedRequests) { if (req.method !== upper) continue; - const segments = normalizePathPattern(req.path).split('/').filter(Boolean); + const segments = generalizeUrl(req.path, () => '{id}').split('/').filter(Boolean); if (segments.length < search.length) continue; if (!search.every((segment, i) => segment === segments[i])) continue; @@ -194,10 +194,3 @@ export class RequestStore { export function isFailedRequest(request: RequestResult): boolean { return request.status >= 400 || Boolean(request.error); } - -function normalizePathPattern(urlPath: string): string { - return urlPath - .split('/') - .map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment)) - .join('/'); -} diff --git a/src/utils/url-matcher.ts b/src/utils/url-matcher.ts index e629fc21..b82b3462 100644 --- a/src/utils/url-matcher.ts +++ b/src/utils/url-matcher.ts @@ -52,10 +52,10 @@ export function generalizeSegment(segment: string): string { return '[^/]+'; } -export function generalizeUrl(url: string): string { +export function generalizeUrl(url: string, replaceSegment: (segment: string) => string = generalizeSegment): string { return url .split('/') - .map((seg) => (seg.length > 0 && isDynamicSegment(seg) ? generalizeSegment(seg) : seg)) + .map((seg) => (seg.length > 0 && isDynamicSegment(seg) ? replaceSegment(seg) : seg)) .join('/'); } From 7b4ef8e0b57efd34829a3d3f6335ae4e5aacf30e Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:36:13 +0300 Subject: [PATCH 4/9] Make dynamicPageRegex extend the built-in dynamic-segment heuristics instead of replacing them Co-Authored-By: Claude --- src/utils/url-matcher.ts | 2 +- tests/unit/url-matcher.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/url-matcher.ts b/src/utils/url-matcher.ts index b82b3462..f6a5452b 100644 --- a/src/utils/url-matcher.ts +++ b/src/utils/url-matcher.ts @@ -4,7 +4,7 @@ import { ConfigParser } from '../config.js'; export function isDynamicSegment(segment: string): boolean { try { const configRegex = ConfigParser.getInstance().getConfig().dynamicPageRegex; - if (configRegex) return new RegExp(configRegex, 'i').test(segment); + if (configRegex && new RegExp(configRegex, 'i').test(segment)) return true; } catch { /* config not loaded yet */ } diff --git a/tests/unit/url-matcher.test.ts b/tests/unit/url-matcher.test.ts index cd18dcd1..e2a7a544 100644 --- a/tests/unit/url-matcher.test.ts +++ b/tests/unit/url-matcher.test.ts @@ -70,12 +70,15 @@ describe('url-matcher', () => { expect(isDynamicSegment('8471')).toBe(true); }); - it('respects user-provided dynamicPageRegex override', () => { + it('user dynamicPageRegex extends the built-in patterns', () => { const instance = ConfigParser.getInstance(); (instance as any).config = { ...(instance as any).config, dynamicPageRegex: '^custom-\\d+$' }; expect(isDynamicSegment('custom-42')).toBe(true); expect(isDynamicSegment('custom-X')).toBe(false); + expect(isDynamicSegment('123')).toBe(true); + expect(isDynamicSegment('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + expect(isDynamicSegment('login')).toBe(false); }); }); From 2952a4ba7ba1b6a9109b7fd7dfd58d206662b18d Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:36:21 +0300 Subject: [PATCH 5/9] Use RequestResult.isWrite instead of a second write-method Set in the request store Co-Authored-By: Claude --- src/api/request-store.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/request-store.ts b/src/api/request-store.ts index b9657e32..92eadbba 100644 --- a/src/api/request-store.ts +++ b/src/api/request-store.ts @@ -159,8 +159,7 @@ export class RequestStore { } getWriteRequestsForScope(scopePath: string): RequestResult[] { - const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); - const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method)); + const writes = this.capturedRequests.filter((r) => r.isWrite); const scopeSegments = scopePath.split('/').filter(Boolean); if (scopeSegments.length === 0) return writes; From c00c42f36f8070aa31fa5934f355eb59809a7449 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:38:54 +0300 Subject: [PATCH 6/9] Drop dead endpoint/method/status queries and collapse getRequests/addRequest aliases in RequestStore Co-Authored-By: Claude --- boat/api-tester/src/ai/curler-tools.ts | 2 +- boat/prima/src/prima.ts | 2 +- boat/prima/tests/prima.test.ts | 2 +- src/api/request-store.ts | 21 --------------------- tests/integration/prima-do.test.ts | 2 +- 5 files changed, 4 insertions(+), 25 deletions(-) diff --git a/boat/api-tester/src/ai/curler-tools.ts b/boat/api-tester/src/ai/curler-tools.ts index c571c1e5..565cb391 100644 --- a/boat/api-tester/src/ai/curler-tools.ts +++ b/boat/api-tester/src/ai/curler-tools.ts @@ -66,7 +66,7 @@ export function createCurlerTools(apiClient: ApiClient, requestState: RequestSto queryParams: input.queryParams, }); - requestState.addRequest(result); + requestState.addMadeRequest(result); if (result.error) { tag('error').log(`${input.method} ${input.path} > Network error: ${result.error}`); diff --git a/boat/prima/src/prima.ts b/boat/prima/src/prima.ts index c5cfdbd4..07866fcd 100644 --- a/boat/prima/src/prima.ts +++ b/boat/prima/src/prima.ts @@ -1113,7 +1113,7 @@ export class Prima { aria: result.ariaSnapshot, html: await result.combinedHtml(), screenshot: result.screenshot, - requests: this.bot.requestStore().getRequests(), + requests: this.bot.requestStore().getMadeRequests(), }); } diff --git a/boat/prima/tests/prima.test.ts b/boat/prima/tests/prima.test.ts index b2087d89..f16f3edd 100644 --- a/boat/prima/tests/prima.test.ts +++ b/boat/prima/tests/prima.test.ts @@ -95,7 +95,7 @@ function fakePrima(options: Record = {}) { }), getCurrentState: () => fakeState(), getConfig: () => ({}), - requestStore: () => ({ getRequests: () => [] }), + requestStore: () => ({ getMadeRequests: () => [] }), getProvider: () => ({ chat: async () => '' }), }; (prima as any).artifactsDir = artifactsRoot; diff --git a/src/api/request-store.ts b/src/api/request-store.ts index 92eadbba..24b8ec15 100644 --- a/src/api/request-store.ts +++ b/src/api/request-store.ts @@ -46,10 +46,6 @@ export class RequestStore { result.save(this.outputDir); } - addRequest(result: RequestResult): void { - this.addMadeRequest(result); - } - getCapturedRequests(): RequestResult[] { return this.capturedRequests; } @@ -58,27 +54,10 @@ export class RequestStore { return this.madeRequests; } - getRequests(): RequestResult[] { - return this.madeRequests; - } - getLastRequest(): RequestResult | undefined { return this.madeRequests[this.madeRequests.length - 1]; } - getRequestsByEndpoint(pathPrefix: string): RequestResult[] { - return this.madeRequests.filter((r) => r.path.startsWith(pathPrefix)); - } - - getRequestsByMethod(method: string): RequestResult[] { - const upper = method.toUpperCase(); - return this.madeRequests.filter((r) => r.method === upper); - } - - getRequestsByStatus(status: number): RequestResult[] { - return this.madeRequests.filter((r) => r.status === status); - } - toEndpointList(scopePath?: string): string { let requests = this.capturedRequests; if (scopePath) requests = this.getWriteRequestsForScope(scopePath); diff --git a/tests/integration/prima-do.test.ts b/tests/integration/prima-do.test.ts index 3de1b9ac..8fa0de56 100644 --- a/tests/integration/prima-do.test.ts +++ b/tests/integration/prima-do.test.ts @@ -90,7 +90,7 @@ describe('Prima.do with aimock', () => { getExplorer: () => ({ action: () => action, capture: async () => null }), stateManager: () => ({ getCurrentState: () => boardState, getVisitCount: () => 1 }), getCurrentState: () => boardState, - requestStore: () => ({ getRequests: () => [] }), + requestStore: () => ({ getMadeRequests: () => [] }), getProvider: () => provider, experienceTracker: () => ({ renderExperienceTocFor: () => '' }), agentResearcher: () => ({}), From e4037bda16ea851358b967a1251a855e13f39578 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:39:13 +0300 Subject: [PATCH 7/9] Use withExecutedSteps as a builder at both call sites in provider.ts Co-Authored-By: Claude --- src/ai/provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/provider.ts b/src/ai/provider.ts index 0bad26d6..baf10e49 100644 --- a/src/ai/provider.ts +++ b/src/ai/provider.ts @@ -428,7 +428,7 @@ export class Provider { let invalidRequestFeedbackAdded = false; const executedStepMessages: ModelMessage[] = []; try { - const response = await this.withModelRequestSlot(() => + let response = await this.withModelRequestSlot(() => withRetry(async () => { const stepMessages: ModelMessage[] = []; const onStepEnd = (step: any) => { @@ -458,7 +458,7 @@ export class Provider { clearActivity(); - withExecutedSteps(response, executedStepMessages); + response = withExecutedSteps(response, executedStepMessages); // Log tool usage summary if (response.toolCalls && response.toolCalls.length > 0) { From 0eca4b3b67b3e728bfd12e576f1e3bc9b82b3fe9 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:39:28 +0300 Subject: [PATCH 8/9] Drop AI SDK v4/v5 usage-field fallbacks that never fire on ai@7 Co-Authored-By: Claude --- src/ai/provider.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/ai/provider.ts b/src/ai/provider.ts index baf10e49..8ef9bfdd 100644 --- a/src/ai/provider.ts +++ b/src/ai/provider.ts @@ -51,12 +51,7 @@ export async function flushTelemetry(): Promise { const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens']; function extractCachedTokens(usage: any): number { - if (!usage) return 0; - const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens; - if (typeof direct === 'number') return direct; - const raw = usage.raw; - const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens; - return typeof fromRaw === 'number' ? fromRaw : 0; + return usage?.inputTokenDetails?.cacheReadTokens ?? 0; } function abortAfterIdle(ms: number, cancel: { cancelled: boolean }, controller: AbortController): Promise { @@ -243,8 +238,8 @@ export class Provider { private recordUsage(agentName: string, modelName: string, usage: any): void { if (!usage) return; Stats.recordTokens(agentName, modelName, { - input: usage.inputTokens ?? usage.promptTokens ?? 0, - output: usage.outputTokens ?? usage.completionTokens ?? 0, + input: usage.inputTokens ?? 0, + output: usage.outputTokens ?? 0, total: usage.totalTokens ?? 0, cached: extractCachedTokens(usage), }); From 2d1baeb2fdf17e7e21f7e074b3b28c0e0ca2c083 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 2 Sep 2026 00:42:42 +0300 Subject: [PATCH 9/9] Add CHANGELOG entry and apply biome formatting for the request-layer/provider cleanup Co-Authored-By: Claude --- CHANGELOG.md | 11 +++++++++++ src/api/request-store.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0252be..3345ce13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-09-02 + +### Changes + +- Config: `dynamicPageRegex` now extends the built-in dynamic-segment heuristics (numeric, UUID, + ULID, hex) instead of replacing them. Previously, setting a custom pattern silently disabled + every built-in match on any segment the custom pattern didn't also cover. +- API Requests: Page-URL generalization and the API endpoint list now share one implementation. + The endpoint list used to walk paths with its own copy of the dynamic-segment logic, which could + drift from the URL matcher used everywhere else. + ## 2026-09-01 ### Changes diff --git a/src/api/request-store.ts b/src/api/request-store.ts index 24b8ec15..0a60c1d5 100644 --- a/src/api/request-store.ts +++ b/src/api/request-store.ts @@ -92,14 +92,18 @@ export class RequestStore { findCapturedRequest(method: string, searchPath: string): RequestResult | undefined { const upper = method.toUpperCase(); - const search = generalizeUrl(searchPath, () => '{id}').split('/').filter(Boolean); + const search = generalizeUrl(searchPath, () => '{id}') + .split('/') + .filter(Boolean); let best: RequestResult | undefined; let bestScore = -1; for (const req of this.capturedRequests) { if (req.method !== upper) continue; - const segments = generalizeUrl(req.path, () => '{id}').split('/').filter(Boolean); + const segments = generalizeUrl(req.path, () => '{id}') + .split('/') + .filter(Boolean); if (segments.length < search.length) continue; if (!search.every((segment, i) => segment === segments[i])) continue;