picocode-core 0.9.170 → 0.9.172
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/package.json +2 -1
- package/src/agent.js +19 -30
- package/src/controller.js +8 -7
- package/src/images.js +127 -0
- package/src/tools/index.js +2 -7
- package/src/tools/web.js +0 -86
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "picocode-core",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.172",
|
|
4
4
|
"description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"diff": "^7.0.0",
|
|
30
30
|
"globby": "^16.2.4",
|
|
31
31
|
"proper-lockfile": "^4.1.2",
|
|
32
|
+
"sharp": "^0.34.5",
|
|
32
33
|
"unpdf": "^1.8.1"
|
|
33
34
|
}
|
|
34
35
|
}
|
package/src/agent.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { prepareImages } from './images.js'
|
|
2
2
|
import { execFile } from 'node:child_process'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
import { compose, scope, model, noToolsCalled, Inherit, getText } from '@prsm/ai'
|
|
5
|
-
import { commitLabel, elementLabel, fileLabel,
|
|
5
|
+
import { commitLabel, elementLabel, fileLabel, selectionLabel } from './attachments.js'
|
|
6
6
|
|
|
7
7
|
const exec = promisify(execFile)
|
|
8
8
|
// a whole patch rides along only while it is small; a bigger commit
|
|
@@ -38,31 +38,16 @@ async function hydratePart(part) {
|
|
|
38
38
|
if (part.type === 'selection') return { type: 'text', text: selectionLabel(part) }
|
|
39
39
|
if (part.type === 'commit') return hydrateCommit(part)
|
|
40
40
|
if (part.type === 'element') return { type: 'text', text: elementLabel(part) }
|
|
41
|
-
|
|
42
|
-
const mediaType = part.source.mediaType || mediaTypeFor(part.source.path)
|
|
43
|
-
if (!mediaType) return { type: 'text', text: `[image unavailable: ${part.source.path}]` }
|
|
44
|
-
try {
|
|
45
|
-
const data = await readFile(part.source.path)
|
|
46
|
-
// an empty file is not an image; sent as one it fails every request
|
|
47
|
-
// that carries this history from then on
|
|
48
|
-
if (!data.length) return { type: 'text', text: `[image unavailable: ${part.source.path} is empty]` }
|
|
49
|
-
return {
|
|
50
|
-
type: 'image',
|
|
51
|
-
source: { kind: 'base64', mediaType, data: data.toString('base64') },
|
|
52
|
-
}
|
|
53
|
-
} catch {
|
|
54
|
-
return { type: 'text', text: `[image unavailable: ${part.source.path}]` }
|
|
55
|
-
}
|
|
41
|
+
return part
|
|
56
42
|
}
|
|
57
43
|
|
|
58
|
-
export function hydrateImages(history) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
44
|
+
export async function hydrateImages(history) {
|
|
45
|
+
const hydrated = await Promise.all(history.map(async (message) =>
|
|
46
|
+
Array.isArray(message.content)
|
|
47
|
+
? { ...message, content: await Promise.all(message.content.map(hydratePart)) }
|
|
48
|
+
: message,
|
|
49
|
+
))
|
|
50
|
+
return prepareImages(hydrated)
|
|
66
51
|
}
|
|
67
52
|
|
|
68
53
|
// reasoning models with large contexts can sit minutes before the first
|
|
@@ -143,7 +128,7 @@ export async function runTurn({ history, tools, recorder, modelName, effort, aut
|
|
|
143
128
|
// a tool may legitimately run for minutes (test suites, slow fetches)
|
|
144
129
|
// and emits nothing while it does; the watchdog guards the provider
|
|
145
130
|
// stream, so pause it until the tool finishes. tools carry their own
|
|
146
|
-
// timeouts (bash 120s default
|
|
131
|
+
// timeouts (bash 120s default)
|
|
147
132
|
clearTimeout(watchdog)
|
|
148
133
|
recorder.currentCall = event.call
|
|
149
134
|
onStream?.(event)
|
|
@@ -172,13 +157,17 @@ export async function runTurn({ history, tools, recorder, modelName, effort, aut
|
|
|
172
157
|
const step = compose(
|
|
173
158
|
scope(
|
|
174
159
|
{ inherit: Inherit.Conversation, system, tools, until: noToolsCalled(), stream },
|
|
175
|
-
(ctx) =>
|
|
176
|
-
model({
|
|
160
|
+
async (ctx) => {
|
|
161
|
+
const out = await model({
|
|
177
162
|
model: modelName,
|
|
178
163
|
...(effort && { effort }),
|
|
179
164
|
...(auth?.apiKey && { apiKey: auth.apiKey }),
|
|
180
165
|
...(auth?.headers && { headers: auth.headers }),
|
|
181
|
-
})({ ...ctx, abortSignal: internal.signal })
|
|
166
|
+
})({ ...ctx, history: await hydrateImages(ctx.history), abortSignal: internal.signal })
|
|
167
|
+
// Prepared bytes and omission notices belong only to the request, not
|
|
168
|
+
// the conversation returned by the provider or subsequent tool rounds.
|
|
169
|
+
return { ...out, history: [...ctx.history, ...out.history.slice(ctx.history.length)] }
|
|
170
|
+
},
|
|
182
171
|
),
|
|
183
172
|
)
|
|
184
173
|
|
|
@@ -190,7 +179,7 @@ export async function runTurn({ history, tools, recorder, modelName, effort, aut
|
|
|
190
179
|
|
|
191
180
|
arm()
|
|
192
181
|
try {
|
|
193
|
-
const out = await step({ history
|
|
182
|
+
const out = await step({ history, tools: [] })
|
|
194
183
|
const interrupted = !!signal?.aborted || stalled
|
|
195
184
|
if (interrupted) {
|
|
196
185
|
return { messages: partialMessages(), usage: usageSeen, lastPromptTokens, interrupted, stalled }
|
package/src/controller.js
CHANGED
|
@@ -53,7 +53,7 @@ export const SESSION_COLORS = {
|
|
|
53
53
|
gray: '#9ca3af',
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const WORKER_TOOLS = ['read', 'write', 'edit', 'bash', 'glob', 'grep', 'shell_output', 'shell_kill'
|
|
56
|
+
const WORKER_TOOLS = ['read', 'write', 'edit', 'bash', 'glob', 'grep', 'shell_output', 'shell_kill']
|
|
57
57
|
const AGENT_TOOLS = ['agent_plan', 'agent_start', 'agent_list', 'agent_collect', 'agent_cancel']
|
|
58
58
|
const CONTEXT_COLORS = ['#67b7ff', '#c792ea', '#f7c66a', '#f78c6c', '#6be795']
|
|
59
59
|
|
|
@@ -202,7 +202,9 @@ export function createController({ boot }) {
|
|
|
202
202
|
const sessionId = state.session?.id
|
|
203
203
|
if (!sessionId) throw new Error('worker requires an active session')
|
|
204
204
|
const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, agent.id))
|
|
205
|
-
const
|
|
205
|
+
const mcpTools = boot.mcp.tools()
|
|
206
|
+
const availableNames = [...WORKER_TOOLS, ...mcpTools.map((tool) => tool.name)]
|
|
207
|
+
const requestedTools = agent.tools?.length ? agent.tools.filter((name) => availableNames.includes(name)) : availableNames
|
|
206
208
|
const { tools, recorder } = createToolset({
|
|
207
209
|
cwd: boot.cwd,
|
|
208
210
|
env: { ...boot.env, PICO_SCRATCHPAD: scratchpad },
|
|
@@ -213,10 +215,10 @@ export function createController({ boot }) {
|
|
|
213
215
|
shells: boot.shells,
|
|
214
216
|
sessionId,
|
|
215
217
|
sessionFile: state.session?.file,
|
|
216
|
-
dredge: boot.dredge,
|
|
217
218
|
signal,
|
|
218
219
|
maxToolCalls: 30,
|
|
219
220
|
allowNames: requestedTools,
|
|
221
|
+
mcpTools,
|
|
220
222
|
})
|
|
221
223
|
return runTurn({
|
|
222
224
|
history: [{ role: 'user', content: agent.prompt }],
|
|
@@ -277,6 +279,7 @@ export function createController({ boot }) {
|
|
|
277
279
|
|
|
278
280
|
const runWorker = async ({ history, role, tools: enabled = true, onStream }) => {
|
|
279
281
|
const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, `deliberation-${id}-${role}`))
|
|
282
|
+
const mcpTools = enabled ? boot.mcp.tools() : []
|
|
280
283
|
const toolset = createToolset({
|
|
281
284
|
cwd: boot.cwd,
|
|
282
285
|
env: { ...boot.env, PICO_SCRATCHPAD: scratchpad },
|
|
@@ -284,10 +287,10 @@ export function createController({ boot }) {
|
|
|
284
287
|
shells: boot.shells,
|
|
285
288
|
sessionId,
|
|
286
289
|
sessionFile: state.session?.file,
|
|
287
|
-
dredge: boot.dredge,
|
|
288
290
|
signal,
|
|
289
291
|
maxToolCalls: 30,
|
|
290
|
-
allowNames: enabled ? WORKER_TOOLS : [],
|
|
292
|
+
allowNames: enabled ? [...WORKER_TOOLS, ...mcpTools.map((tool) => tool.name)] : [],
|
|
293
|
+
mcpTools,
|
|
291
294
|
})
|
|
292
295
|
return runTurn({
|
|
293
296
|
history,
|
|
@@ -622,7 +625,6 @@ export function createController({ boot }) {
|
|
|
622
625
|
deliberations: boot.deliberationModel || (boot.proposerModel && boot.reviewerModel) ? deliberations : null,
|
|
623
626
|
onAgentsCollected: discardCollectedAgentNotes,
|
|
624
627
|
askUser,
|
|
625
|
-
dredge: boot.dredge,
|
|
626
628
|
mcpTools: boot.mcp.tools(),
|
|
627
629
|
userTools: userToolScan.tools,
|
|
628
630
|
signal: controller.signal,
|
|
@@ -1216,7 +1218,6 @@ export function createController({ boot }) {
|
|
|
1216
1218
|
shells: boot.shells,
|
|
1217
1219
|
wakeups: boot.wakeups,
|
|
1218
1220
|
memory: boot.memory,
|
|
1219
|
-
dredge: boot.dredge,
|
|
1220
1221
|
...extra,
|
|
1221
1222
|
})
|
|
1222
1223
|
}
|
package/src/images.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import sharp from 'sharp'
|
|
4
|
+
|
|
5
|
+
// Conservative application budgets, not claims about every provider's limits.
|
|
6
|
+
// OpenAI documents 32px patches and model/detail-specific resizing budgets:
|
|
7
|
+
// https://developers.openai.com/api/docs/guides/images-vision
|
|
8
|
+
// Claude documents 32MB requests and a 2000px side limit
|
|
9
|
+
// above 20 images: https://platform.claude.com/docs/en/build-with-claude/vision
|
|
10
|
+
// Apply the same envelope across models, including Codex (whose endpoint
|
|
11
|
+
// must not be relied on to perform the documented API's automatic resizing).
|
|
12
|
+
export const IMAGE_LIMITS = Object.freeze({
|
|
13
|
+
side: 2000, patches: 1536, count: 20, requestPatches: 12000,
|
|
14
|
+
bytes: 4_000_000, requestBase64Bytes: 20_000_000,
|
|
15
|
+
inputBytes: 64_000_000, inputPixels: 100_000_000,
|
|
16
|
+
})
|
|
17
|
+
const patches = (w, h) => Math.ceil(w / 32) * Math.ceil(h / 32)
|
|
18
|
+
|
|
19
|
+
export function imageUnavailable(part, reason) {
|
|
20
|
+
const label = part.source?.kind === 'path' ? part.source.path : 'attached image'
|
|
21
|
+
return { type: 'text', text: `[image unavailable: ${label}; ${reason}. Original unchanged. Use a smaller crop or paginated screenshot, or attach the image again.]` }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function imageDimensions(width, height) {
|
|
25
|
+
let scale = Math.min(1, IMAGE_LIMITS.side / width, IMAGE_LIMITS.side / height,
|
|
26
|
+
Math.sqrt(IMAGE_LIMITS.patches * 32 * 32 / (width * height)))
|
|
27
|
+
let w = Math.max(1, Math.floor(width * scale))
|
|
28
|
+
let h = Math.max(1, Math.floor(height * scale))
|
|
29
|
+
// Rounding UP to patches matters, especially for long, narrow screenshots.
|
|
30
|
+
while (patches(w, h) > IMAGE_LIMITS.patches) {
|
|
31
|
+
scale *= 0.99
|
|
32
|
+
w = Math.max(1, Math.floor(width * scale))
|
|
33
|
+
h = Math.max(1, Math.floor(height * scale))
|
|
34
|
+
}
|
|
35
|
+
return { width: w, height: h }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function imageBytes(source) {
|
|
39
|
+
if (source?.kind === 'path') {
|
|
40
|
+
const file = await open(source.path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0))
|
|
41
|
+
try {
|
|
42
|
+
const stat = await file.stat()
|
|
43
|
+
if (!stat.isFile()) throw new Error('not a regular image file')
|
|
44
|
+
if (stat.size > IMAGE_LIMITS.inputBytes) throw new Error('source exceeds the 64MB safety limit')
|
|
45
|
+
// Bounded even if the file grows after stat.
|
|
46
|
+
const data = Buffer.alloc(Math.min(stat.size + 1, IMAGE_LIMITS.inputBytes + 1))
|
|
47
|
+
let size = 0
|
|
48
|
+
while (size < data.length) {
|
|
49
|
+
const { bytesRead } = await file.read(data, size, data.length - size, null)
|
|
50
|
+
if (!bytesRead) break
|
|
51
|
+
size += bytesRead
|
|
52
|
+
}
|
|
53
|
+
if (size > stat.size) throw new Error('image changed while reading; retry')
|
|
54
|
+
return data.subarray(0, size)
|
|
55
|
+
} finally { await file.close() }
|
|
56
|
+
}
|
|
57
|
+
let encoded = source?.kind === 'base64' ? source.data : null
|
|
58
|
+
if (source?.kind === 'url') {
|
|
59
|
+
encoded = /^data:image\/[\w.+-]+;base64,([\s\S]*)$/i.exec(source.url)?.[1]
|
|
60
|
+
}
|
|
61
|
+
if (typeof encoded !== 'string') throw new Error('unvalidated remote or unsupported image source; download it to a local file first')
|
|
62
|
+
if (encoded.length > Math.ceil(IMAGE_LIMITS.inputBytes / 3) * 4) throw new Error('source exceeds the 64MB safety limit')
|
|
63
|
+
encoded = encoded.replace(/\s/g, '')
|
|
64
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 === 1) throw new Error('invalid base64 image')
|
|
65
|
+
const bytes = Buffer.from(encoded, 'base64')
|
|
66
|
+
if (bytes.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) throw new Error('invalid base64 image')
|
|
67
|
+
return bytes
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function prepareImage(part) {
|
|
71
|
+
try {
|
|
72
|
+
const bytes = await imageBytes(part.source)
|
|
73
|
+
if (!bytes.length) throw new Error('image is empty')
|
|
74
|
+
const options = { limitInputPixels: IMAGE_LIMITS.inputPixels, failOn: 'warning' }
|
|
75
|
+
const metadata = await sharp(bytes, options).metadata()
|
|
76
|
+
if (!['png', 'jpeg', 'webp', 'gif'].includes(metadata.format)) throw new Error('unsupported image format; use PNG, JPEG, WebP or GIF')
|
|
77
|
+
if (!metadata.width || !metadata.height) throw new Error('invalid image dimensions')
|
|
78
|
+
const rotated = [5, 6, 7, 8].includes(metadata.orientation)
|
|
79
|
+
const dimensions = imageDimensions(rotated ? metadata.height : metadata.width, rotated ? metadata.width : metadata.height)
|
|
80
|
+
// Decode fully: metadata alone accepts truncated/corrupt pixel data. Always
|
|
81
|
+
// emit a static, correctly labelled image; animation uses its first frame.
|
|
82
|
+
const image = sharp(bytes, options).rotate().resize({ ...dimensions, fit: 'fill' })
|
|
83
|
+
let result = await image.png().toBuffer({ resolveWithObject: true })
|
|
84
|
+
let mediaType = 'image/png'
|
|
85
|
+
if (result.data.length > IMAGE_LIMITS.bytes) {
|
|
86
|
+
result = await sharp(result.data).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer({ resolveWithObject: true })
|
|
87
|
+
mediaType = 'image/jpeg'
|
|
88
|
+
}
|
|
89
|
+
if (result.data.length > IMAGE_LIMITS.bytes) throw new Error('encoded image exceeds the 4MB safety limit')
|
|
90
|
+
return { part: { type: 'image', source: { kind: 'base64', mediaType, data: result.data.toString('base64') } }, patches: patches(result.info.width, result.info.height) }
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Do not expose decoder internals or base64 payloads to the model.
|
|
93
|
+
const reason = error.code === 'ENOENT' ? 'file is missing' :
|
|
94
|
+
/^(image |source |invalid |unsupported |unvalidated |not a regular|encoded image)/.test(error.message) ? error.message : 'cannot read or decode image safely'
|
|
95
|
+
return { part: imageUnavailable(part, reason), patches: 0 }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Sequential and newest-first: bound decoder memory, and retain the images
|
|
100
|
+
// relevant to the current task rather than letting old history exhaust budgets.
|
|
101
|
+
export async function prepareImages(history) {
|
|
102
|
+
const out = history.map(message => Array.isArray(message.content) ? { ...message, content: [...message.content] } : message)
|
|
103
|
+
let count = 0, totalPatches = 0, totalBytes = 0
|
|
104
|
+
for (let m = out.length - 1; m >= 0; m--) {
|
|
105
|
+
const content = out[m].content
|
|
106
|
+
if (!Array.isArray(content)) continue
|
|
107
|
+
for (let p = content.length - 1; p >= 0; p--) {
|
|
108
|
+
const original = content[p]
|
|
109
|
+
if (original.type !== 'image') continue
|
|
110
|
+
if (count >= IMAGE_LIMITS.count) {
|
|
111
|
+
content[p] = imageUnavailable(original, 'request image count safety budget reached; newest images retained')
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
const prepared = await prepareImage(original)
|
|
115
|
+
const size = prepared.part.source?.data.length || 0
|
|
116
|
+
if (totalPatches + prepared.patches > IMAGE_LIMITS.requestPatches || totalBytes + size > IMAGE_LIMITS.requestBase64Bytes) {
|
|
117
|
+
content[p] = imageUnavailable(original, 'request image safety budget reached; newer images take priority')
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
content[p] = prepared.part
|
|
121
|
+
if (prepared.part.type === 'image') count++
|
|
122
|
+
totalPatches += prepared.patches
|
|
123
|
+
totalBytes += size
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -5,10 +5,9 @@ import { createEdit } from './edit.js'
|
|
|
5
5
|
import { createBash } from './bash.js'
|
|
6
6
|
import { createGlob } from './glob.js'
|
|
7
7
|
import { createGrep } from './grep.js'
|
|
8
|
-
import { createWebTools } from './web.js'
|
|
9
8
|
import { createView } from './view.js'
|
|
10
9
|
|
|
11
|
-
export function createToolset({ cwd, env, tracker, skills, shells, sessionId, sessionFile, wakeups, memory, agents, deliberations, onAgentsCollected, askUser,
|
|
10
|
+
export function createToolset({ cwd, env, tracker, skills, shells, sessionId, sessionFile, wakeups, memory, agents, deliberations, onAgentsCollected, askUser, mcpTools = [], userTools = [], hostTools = [], signal, maxToolCalls, maxAgentStarts, requireAgentPlan = false, allowNames, onToolUpdate, viewer }) {
|
|
12
11
|
const recorder = createRecorder(onToolUpdate)
|
|
13
12
|
let agentStarts = 0
|
|
14
13
|
let plannedAgentStarts = requireAgentPlan ? null : maxAgentStarts
|
|
@@ -25,10 +24,6 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
|
|
|
25
24
|
|
|
26
25
|
if (viewer) local.push(createView({ ...deps, viewer }))
|
|
27
26
|
|
|
28
|
-
if (dredge) {
|
|
29
|
-
local.push(...createWebTools({ dredge, recorder, signal }))
|
|
30
|
-
}
|
|
31
|
-
|
|
32
27
|
if (shells) {
|
|
33
28
|
local.push(
|
|
34
29
|
{
|
|
@@ -283,7 +278,7 @@ export function createToolset({ cwd, env, tracker, skills, shells, sessionId, se
|
|
|
283
278
|
})
|
|
284
279
|
}
|
|
285
280
|
|
|
286
|
-
const describedToolNames = new Set(['read', 'write', 'edit', 'bash', 'glob', 'grep', '
|
|
281
|
+
const describedToolNames = new Set(['read', 'write', 'edit', 'bash', 'glob', 'grep', 'schedule_wakeup'])
|
|
287
282
|
const describedTools = new Set(local.filter((tool) => describedToolNames.has(tool.name)).map((tool) => tool.name))
|
|
288
283
|
const byName = new Map()
|
|
289
284
|
for (const tool of [...local, ...hostTools, ...userTools, ...mcpTools]) {
|
package/src/tools/web.js
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { describeParam } from './recorder.js'
|
|
2
|
-
const DEFAULT_SLICE_CHARS = 24000
|
|
3
|
-
const MAX_SLICE_CHARS = 100000
|
|
4
|
-
|
|
5
|
-
export function resolveDredge(config = {}, env = process.env) {
|
|
6
|
-
const url = env.DREDGE_URL || config.dredge?.url || null
|
|
7
|
-
if (!url) return null
|
|
8
|
-
return {
|
|
9
|
-
url: url.replace(/\/+$/, ''),
|
|
10
|
-
apiKey: env.DREDGE_API_KEY || config.dredge?.apiKey || null,
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const CALL_TIMEOUT_MS = 120000
|
|
15
|
-
|
|
16
|
-
async function call(dredge, path, params, signal) {
|
|
17
|
-
const query = new URLSearchParams(params)
|
|
18
|
-
// bounded above dredge's own 90s queue ceiling so a wedged server becomes
|
|
19
|
-
// a tool error instead of a hung turn
|
|
20
|
-
const signals = [AbortSignal.timeout(CALL_TIMEOUT_MS), ...(signal ? [signal] : [])]
|
|
21
|
-
const response = await fetch(`${dredge.url}${path}?${query}`, {
|
|
22
|
-
signal: AbortSignal.any(signals),
|
|
23
|
-
headers: dredge.apiKey ? { authorization: `Bearer ${dredge.apiKey}` } : {},
|
|
24
|
-
})
|
|
25
|
-
const body = await response.json().catch(() => null)
|
|
26
|
-
if (!body) throw new Error(`dredge returned http ${response.status} with no usable body`)
|
|
27
|
-
return body
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function createWebTools({ dredge, recorder, signal }) {
|
|
31
|
-
return [
|
|
32
|
-
{
|
|
33
|
-
name: 'web_search',
|
|
34
|
-
description:
|
|
35
|
-
'Search the web. Google-style operators work: site:, filetype:pdf, quoted phrases. Returns ranked results; pass a result url to web_fetch to read it.',
|
|
36
|
-
schema: {
|
|
37
|
-
q: { type: 'string', description: 'the search query' },
|
|
38
|
-
description: describeParam,
|
|
39
|
-
},
|
|
40
|
-
execute: async ({ q }) => {
|
|
41
|
-
recorder.extra({ title: q })
|
|
42
|
-
const body = await call(dredge, '/search', { q }, signal)
|
|
43
|
-
if (!body.ok) throw new Error(body.error?.message || 'search failed')
|
|
44
|
-
const results = (body.results || []).map((r) => ({
|
|
45
|
-
title: r.title,
|
|
46
|
-
url: r.url,
|
|
47
|
-
snippet: r.snippet,
|
|
48
|
-
source: r.source,
|
|
49
|
-
}))
|
|
50
|
-
if (results.length === 0) {
|
|
51
|
-
const backends = (body.backends || []).map((b) => `${b.name}: ${b.status}`).join(', ')
|
|
52
|
-
return { results, note: backends ? `no results · backends: ${backends}` : 'no results' }
|
|
53
|
-
}
|
|
54
|
-
return { results }
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
name: 'web_fetch',
|
|
59
|
-
description:
|
|
60
|
-
'Fetch a url and read it as clean markdown (html, pdf, docx, and textual formats like json). Long documents arrive in slices: the result says which slice you have (e.g. "slice 1 of 12") and next_cursor continues from there. Every slice you fetch permanently occupies conversation context, so only walk cursors for content you actually need, and raise maxChars only when the task genuinely needs a bigger window.',
|
|
61
|
-
schema: {
|
|
62
|
-
description: describeParam,
|
|
63
|
-
url: { type: 'string', description: 'the url to fetch' },
|
|
64
|
-
cursor: { type: 'string', description: 'pagination cursor from a previous web_fetch of the same url', optional: true },
|
|
65
|
-
maxChars: { type: 'number', description: `slice size in characters, default ${DEFAULT_SLICE_CHARS}, max ${MAX_SLICE_CHARS}`, optional: true },
|
|
66
|
-
},
|
|
67
|
-
execute: async ({ url, cursor, maxChars }) => {
|
|
68
|
-
recorder.extra({ title: url.replace(/^https?:\/\//, '').slice(0, 80) })
|
|
69
|
-
const slice = Math.min(MAX_SLICE_CHARS, Math.max(1000, maxChars || DEFAULT_SLICE_CHARS))
|
|
70
|
-
const body = await call(dredge, '/fetch', { url, maxChars: slice, ...(cursor && { cursor }) }, signal)
|
|
71
|
-
if (!body.ok) {
|
|
72
|
-
const { code, message, retryable } = body.error || {}
|
|
73
|
-
throw new Error(`${code || 'fetch failed'}: ${message || url}${retryable ? ' (retryable)' : ''}`)
|
|
74
|
-
}
|
|
75
|
-
const { markdown, metadata, pagination } = body.doc
|
|
76
|
-
return {
|
|
77
|
-
markdown,
|
|
78
|
-
title: metadata?.title || null,
|
|
79
|
-
finalUrl: metadata?.final_url || url,
|
|
80
|
-
...(pagination?.total_chunks > 1 && { slice: `${pagination.chunk_index + 1} of ${pagination.total_chunks}` }),
|
|
81
|
-
...(pagination?.next_cursor && { next_cursor: pagination.next_cursor }),
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
]
|
|
86
|
-
}
|