@jangmanj/pi-webtasks 0.1.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/LICENSE +21 -0
- package/README.md +193 -0
- package/package.json +64 -0
- package/src/alert-delivery.ts +128 -0
- package/src/answer-service.ts +237 -0
- package/src/env.ts +68 -0
- package/src/index.ts +175 -0
- package/src/ops.ts +64 -0
- package/src/paid-call.ts +202 -0
- package/src/parallel-oauth.ts +249 -0
- package/src/provider.ts +63 -0
- package/src/recall.ts +151 -0
- package/src/receipt.ts +95 -0
- package/src/research-service.ts +407 -0
- package/src/research-tools.ts +244 -0
- package/src/source-policy.ts +60 -0
- package/src/status.ts +154 -0
- package/src/store.ts +1445 -0
- package/src/task-run-lifecycle.ts +393 -0
- package/src/task-service.ts +489 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JangMan-J
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# pi-webtasks
|
|
2
|
+
|
|
3
|
+
Session-bound web research for Pi, with a local evidence store.
|
|
4
|
+
|
|
5
|
+
The extension owns transport, storage, indexing, recovery, and delivery. The model
|
|
6
|
+
owns the research work: what to search, what to read, and what to ask. Nothing
|
|
7
|
+
exposes SQL, provider ids, file paths, or raw HTTP.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pi install npm:@jangmanj/pi-webtasks
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then run `/login` in Pi and choose **Parallel** (see [Credentials](#credentials)).
|
|
16
|
+
|
|
17
|
+
This package was earlier published unscoped as `pi-webtasks`; that name is no
|
|
18
|
+
longer published. If you installed it, run `pi remove npm:pi-webtasks`, then
|
|
19
|
+
install the scoped name. The research store directory is the same, so stored
|
|
20
|
+
evidence carries over.
|
|
21
|
+
|
|
22
|
+
## Status
|
|
23
|
+
|
|
24
|
+
Five tools, split by who writes the answer. The agent writes it from pages it
|
|
25
|
+
reads (`web_search`, `web_fetch`), or a Parallel research agent writes it
|
|
26
|
+
(`web_answer`, `web_research`):
|
|
27
|
+
|
|
28
|
+
| Tool | Work |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `web_search` | Find pages for an objective and 1-3 keyword queries; store the ranked sources and excerpts and return handles. |
|
|
31
|
+
| `web_fetch` | Read known http(s) URLs (pages, PDFs) into the same store, as focused excerpts or full content. |
|
|
32
|
+
| `web_answer` | Ask Parallel's research agent (Responses API) for a cited answer in seconds, and follow up in the same conversation. |
|
|
33
|
+
| `web_research` | Hand a research job to Parallel's deep-research agent (Task API) in the background: a cited report or structured fields with per-field citations. `core` is the processor floor and `ultra2x` the most an agent may choose; progress and completion arrive as alerts. |
|
|
34
|
+
| `web_recall` | Search stored evidence, or read one item by handle in bounded slices. |
|
|
35
|
+
|
|
36
|
+
The agent-facing wording (tool descriptions, guidelines, receipts, and errors)
|
|
37
|
+
describes Parallel's capabilities and what to do next. It does not expose the
|
|
38
|
+
extension's policies or plumbing: processor limits, event streams, and the
|
|
39
|
+
store stay internal. `tests/tool-wording.test.ts` checks that every guideline
|
|
40
|
+
names its own tool, because Pi merges all guidelines into one list.
|
|
41
|
+
|
|
42
|
+
There are no webhooks, no background receiver, no artifact files, and no awkward
|
|
43
|
+
effort tiers. Collection happens inside Pi; if Pi is closed, collection stops
|
|
44
|
+
and the next session reconciles provider state where retention permits.
|
|
45
|
+
|
|
46
|
+
## Background research
|
|
47
|
+
|
|
48
|
+
`web_research` requires an explicit output contract: either `output_schema` (a
|
|
49
|
+
JSON Schema object) for fields, or `output_description` for the cited text report
|
|
50
|
+
you want. The extension never invents one, and never relies on the provider's
|
|
51
|
+
`auto` default. It submits once and returns a receipt. The run's event stream is
|
|
52
|
+
collected while Pi is open: every event is recorded, and interim findings become
|
|
53
|
+
stored evidence as they arrive, so `web_recall` can read partial results
|
|
54
|
+
before the run finishes. Progress messages and source statistics feed the
|
|
55
|
+
progress alerts. If the stream drops, it reconnects with backoff (3 s up to
|
|
56
|
+
5 min).
|
|
57
|
+
|
|
58
|
+
Alerts wake an idle session and queue behind a busy one; they never steer or
|
|
59
|
+
interrupt active work. Progress updates follow one third of the lower published
|
|
60
|
+
upper-latency estimate per processor (for example `core` every 1m40s). They are
|
|
61
|
+
reminders to look, not deadlines or completion percentages. Completion, failure,
|
|
62
|
+
and action-required alerts are immediate. A run that Parallel halts as
|
|
63
|
+
`action_required` is alerted once and stays watched: progress alerts pause, and
|
|
64
|
+
when it resumes or ends, that arrives as another alert. A closed stream is never
|
|
65
|
+
treated as completion: the provider's run status decides.
|
|
66
|
+
|
|
67
|
+
Alerts are persisted before delivery and delivered at-least-once: an alert stays
|
|
68
|
+
unacknowledged until the agent reads that run, and a new runtime offers
|
|
69
|
+
unacknowledged alerts again rather than losing them. Do not expect exactly-once.
|
|
70
|
+
|
|
71
|
+
Task runs carry the local reference in their metadata inside the session's Task
|
|
72
|
+
Group, so a crash between submission and recording the provider id is recovered
|
|
73
|
+
from the group instead of resubmitting. `core` is the processor floor
|
|
74
|
+
(`lite`/`base` are not offered for research) and `ultra2x` is what an agent may
|
|
75
|
+
choose; the configured default is `core` (`WEBTASKS_TASK_PROCESSOR` overrides).
|
|
76
|
+
|
|
77
|
+
## How evidence behaves
|
|
78
|
+
|
|
79
|
+
- Every Search and Extract result is stored in `research.sqlite` under the Pi
|
|
80
|
+
session that requested it (`WEBTASKS_DIR`, else `<agent-dir>/webtasks`).
|
|
81
|
+
- The model sees a bounded receipt with stable handles, never the whole corpus.
|
|
82
|
+
`web_recall { handle }` returns a bounded slice plus the next offset;
|
|
83
|
+
`web_recall { query }` searches stored text and returns handles.
|
|
84
|
+
- Evidence belongs to one Pi session. A handle from another session behaves
|
|
85
|
+
exactly like a missing one, so nothing leaks across sessions.
|
|
86
|
+
- Receipts return a local **context handle**. Passing it as `context` to a later
|
|
87
|
+
`web_search`/`web_fetch` keeps related calls in one provider research session;
|
|
88
|
+
the provider id itself never leaves the store.
|
|
89
|
+
- A paid call is recorded before it is sent and is never retried automatically.
|
|
90
|
+
Replaying the same Pi invocation returns what is already stored instead of
|
|
91
|
+
paying twice; an interrupted request is marked `uncertain`, not silently
|
|
92
|
+
resubmitted. Every paid tool settles a failure the same way: a missing key or
|
|
93
|
+
a 4xx rejection is `failed`; anything else that may have reached Parallel
|
|
94
|
+
(5xx, 408, a lost connection, a timeout) is `uncertain`.
|
|
95
|
+
- Retrieved text is untrusted material, not instructions.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
Requirements: Node.js 22.19 or later, npm, and Pi 1.1 or later.
|
|
100
|
+
The only runtime dependency is the pinned `parallel-web` SDK. SQLite uses Node's
|
|
101
|
+
built-in implementation.
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
npm ci
|
|
105
|
+
npm run check
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`npm run check` runs the type checker and the offline suite. The offline suite
|
|
109
|
+
uses real SQLite in temporary directories and a stubbed HTTP layer; it makes no
|
|
110
|
+
paid request. `PI_PACKAGE_DIR` selects another installed Pi package directory for
|
|
111
|
+
runtime tests.
|
|
112
|
+
|
|
113
|
+
`node scripts/extract-tool-schemas.ts <dir>` dumps the registered tools exactly
|
|
114
|
+
as the model sees them.
|
|
115
|
+
|
|
116
|
+
`npm run smoke:pack` packs the package, installs the tarball into a scratch
|
|
117
|
+
project, and loads it through Pi's real extension loader.
|
|
118
|
+
|
|
119
|
+
CI (`.github/workflows/ci.yml`) runs the type checker, the suite, and the pack
|
|
120
|
+
smoke test on Node 22.19 (the engines floor), 24, and 26, plus Pi 1.1.0 (the
|
|
121
|
+
peer floor).
|
|
122
|
+
|
|
123
|
+
## Releasing
|
|
124
|
+
|
|
125
|
+
Releases publish from GitHub Actions through npm trusted publishing (OIDC), so
|
|
126
|
+
no npm token is stored anywhere and every version carries a provenance
|
|
127
|
+
attestation.
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
npm version patch # or minor / major: bumps package.json, commits, tags vX.Y.Z
|
|
131
|
+
git push --follow-tags # the tag runs .github/workflows/publish.yml
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The publish workflow reruns CI, refuses a tag that does not match
|
|
135
|
+
`package.json`, publishes, and creates the GitHub release.
|
|
136
|
+
|
|
137
|
+
One-time setup for a package name that is not on npm yet: `npm login`, then
|
|
138
|
+
`npm run bootstrap:npm` in an interactive terminal (one 2FA approval). The
|
|
139
|
+
script stages a throwaway `0.0.0-bootstrap` to create the package, links
|
|
140
|
+
`publish.yml` (environment `npm`) as its trusted publisher, and pushes the
|
|
141
|
+
`v<version>` tag, so CI publishes the first real version with provenance. Run
|
|
142
|
+
`npm run bootstrap:npm -- --dry-run` first to check the plan. Later,
|
|
143
|
+
`npm run bootstrap:npm -- --cleanup` removes the staged throwaway and the
|
|
144
|
+
`0.0.0-stage` placeholder (two more approvals).
|
|
145
|
+
|
|
146
|
+
Retired experiments, benchmarks, and the pre-rewrite implementation live in
|
|
147
|
+
`.archive/` (git-ignored). `npm run docs:snapshot` refreshes the Parallel docs
|
|
148
|
+
mirror into `docs/parallel/` (git-ignored); the design record and API notes live
|
|
149
|
+
there too, outside version control.
|
|
150
|
+
|
|
151
|
+
## Credentials
|
|
152
|
+
|
|
153
|
+
Run `/login` in Pi and choose **Parallel**. Pi offers two sign-in methods:
|
|
154
|
+
|
|
155
|
+
- **Browser sign-in** (listed under *Sign in with an account*). Pi opens
|
|
156
|
+
platform.parallel.ai in your browser. There you approve the request for
|
|
157
|
+
`127.0.0.1` and select an existing API key or create one. Pi receives the
|
|
158
|
+
approval on a temporary listener on `127.0.0.1`, exchanges it for the key, and
|
|
159
|
+
stores it. The listener accepts one callback and closes on success, error, or
|
|
160
|
+
cancel. After 120 seconds, Pi offers a prompt for the callback address from the
|
|
161
|
+
browser's address bar, for example over SSH without port forwarding. The
|
|
162
|
+
listener stays open while that prompt waits, so a browser callback can still
|
|
163
|
+
complete sign-in.
|
|
164
|
+
- **API key** (listed under *Sign in with an API key*). Paste a key from the API
|
|
165
|
+
keys page on platform.parallel.ai.
|
|
166
|
+
|
|
167
|
+
An empty entry stores nothing. Parallel adds no models, so the model picker is
|
|
168
|
+
unchanged. The extension registers no commands.
|
|
169
|
+
|
|
170
|
+
The stored login is used first. Without one, the extension uses
|
|
171
|
+
`_PARALLEL_API_KEY` (`PARALLEL_API_KEY` is also accepted), then a key in `.env` or
|
|
172
|
+
`parallel.env` beside the package manifest. Exported settings take precedence
|
|
173
|
+
over the key files. The key is resolved on every call, so a login or logout takes
|
|
174
|
+
effect on the next request. Credential files are not packaged or committed.
|
|
175
|
+
|
|
176
|
+
Do not install `@parallel-web/pi-extension` alongside this package: it registers a
|
|
177
|
+
`web_search` tool with the same name, so the two would collide.
|
|
178
|
+
|
|
179
|
+
Balance is not shown by the extension; check the provider account directly. Do
|
|
180
|
+
not paste credential values into tool arguments, diagnostics, or source files.
|
|
181
|
+
|
|
182
|
+
## Limits
|
|
183
|
+
|
|
184
|
+
- Search, Extract, and Answers are synchronous calls; only research tasks run in
|
|
185
|
+
the background.
|
|
186
|
+
- `web_recall` returns bounded pages and slices, never a whole corpus in one
|
|
187
|
+
call. A page cursor carries its own search, so `web_recall { cursor }`
|
|
188
|
+
alone continues it.
|
|
189
|
+
- Collection stops when Pi closes. Intermediate progress from while Pi was closed
|
|
190
|
+
is not replayed by the provider; completed results are recovered where the
|
|
191
|
+
account's retention policy allows.
|
|
192
|
+
- Stored evidence is private data under the agent directory; retention and
|
|
193
|
+
cross-session visibility are still open decisions.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jangmanj/pi-webtasks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Session-bound web research tools with a local evidence store for Pi.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "JangMan-J",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/JangMan-J/pi-webtasks.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/JangMan-J/pi-webtasks#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/JangMan-J/pi-webtasks/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"parallel",
|
|
20
|
+
"web-search",
|
|
21
|
+
"research"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.19.0"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./src/index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test tests/*.test.ts",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"check": "npm run typecheck && npm test",
|
|
39
|
+
"smoke:pack": "node scripts/smoke-pack.mjs",
|
|
40
|
+
"docs:snapshot": "node scripts/snapshot-docs.mjs docs/parallel",
|
|
41
|
+
"bootstrap:npm": "bash scripts/npm-bootstrap.sh"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"parallel-web": "1.3.3"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@earendil-works/pi-ai": "*",
|
|
48
|
+
"@earendil-works/pi-coding-agent": ">=1.1.0",
|
|
49
|
+
"typebox": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
53
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
54
|
+
"@types/node": "26.6.2",
|
|
55
|
+
"typebox": "1.3.27",
|
|
56
|
+
"typescript": "6.0.3"
|
|
57
|
+
},
|
|
58
|
+
"overrides": {
|
|
59
|
+
"undici": "8.12.3"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert delivery: hands the session's stored alerts to the agent, at least once.
|
|
3
|
+
*
|
|
4
|
+
* Alerts are written by the store: a completion commits its alert in the same
|
|
5
|
+
* transaction as its evidence, and progress alerts are queued on their
|
|
6
|
+
* schedule. This module owns everything after that:
|
|
7
|
+
*
|
|
8
|
+
* - Draining: claim waiting alerts (failures and attention first, then
|
|
9
|
+
* completions, then progress), hand each to the sink, mark it delivered.
|
|
10
|
+
* Wake-ups coalesce: one drain runs at a time, and a wake-up during a drain
|
|
11
|
+
* makes it look again before it goes idle.
|
|
12
|
+
* - A failed hand-off is not delivery: the alert stays claimed and the next
|
|
13
|
+
* wake-up offers it again.
|
|
14
|
+
* - Delivered is not received: an alert stays open until the agent reads its
|
|
15
|
+
* run (`received`), and a new runtime offers every unacknowledged alert
|
|
16
|
+
* again (`start`). Duplicates are possible; silence is not.
|
|
17
|
+
* - Nothing is handed over before `start` or after `stop`, and `stop` waits
|
|
18
|
+
* for a running drain, so the store is never closed under it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AlertRecord, ResearchStore } from "./store.ts";
|
|
22
|
+
|
|
23
|
+
/** Hands one alert to the agent. Throwing means it was not handed over. */
|
|
24
|
+
export type AlertSink = (alert: AlertRecord) => void | Promise<void>;
|
|
25
|
+
|
|
26
|
+
type Options = {
|
|
27
|
+
store: ResearchStore;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
deliver: AlertSink;
|
|
30
|
+
/** Reports a failed hand-off or an unavailable store. Delivery itself never throws. */
|
|
31
|
+
onError?: (err: unknown) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Alerts claimed per pass. */
|
|
35
|
+
const BATCH = 5;
|
|
36
|
+
/** Passes per drain; anything left waits for the next wake-up. */
|
|
37
|
+
const MAX_PASSES = 20;
|
|
38
|
+
|
|
39
|
+
export function createAlertDelivery(options: Options) {
|
|
40
|
+
const { store, sessionId, deliver } = options;
|
|
41
|
+
const report = options.onError ?? (() => undefined);
|
|
42
|
+
/** The takeover and first drain; set once by `start`. */
|
|
43
|
+
let startup: Promise<void> | undefined;
|
|
44
|
+
let started = false;
|
|
45
|
+
let stopped = false;
|
|
46
|
+
let draining = false;
|
|
47
|
+
/** A wake-up arrived during the running drain: look once more before going idle. */
|
|
48
|
+
let again = false;
|
|
49
|
+
let current: Promise<void> = Promise.resolve();
|
|
50
|
+
|
|
51
|
+
async function drain(): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
54
|
+
if (stopped) return;
|
|
55
|
+
again = false;
|
|
56
|
+
const batch = await store.claimAlerts(sessionId, { limit: BATCH });
|
|
57
|
+
if (!batch.length) {
|
|
58
|
+
if (again) continue;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const alert of batch) {
|
|
62
|
+
if (stopped) return;
|
|
63
|
+
try {
|
|
64
|
+
await deliver(alert);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// Not marked delivered: the alert stays claimed and the next wake-up offers it again.
|
|
67
|
+
report(err);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Unmarked, it is offered again: a duplicate, never a loss.
|
|
71
|
+
await store.markAlertDelivered(sessionId, alert.alert_id).catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
report(err);
|
|
76
|
+
} finally {
|
|
77
|
+
// Cleared in the same step as the last check of `again`, so no wake-up falls between them.
|
|
78
|
+
draining = false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Something may be waiting: drain it, or make the running drain look again.
|
|
84
|
+
* Resolves when that drain is idle. Never rejects.
|
|
85
|
+
*/
|
|
86
|
+
function wake(): Promise<void> {
|
|
87
|
+
if (!started || stopped) return current;
|
|
88
|
+
if (draining) {
|
|
89
|
+
again = true;
|
|
90
|
+
return current;
|
|
91
|
+
}
|
|
92
|
+
draining = true;
|
|
93
|
+
current = drain();
|
|
94
|
+
return current;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Take over delivery for this session: what an earlier runtime handed over
|
|
99
|
+
* but never saw read is offered again, then everything waiting is drained.
|
|
100
|
+
* Wake-ups before this are covered by its drain.
|
|
101
|
+
*/
|
|
102
|
+
function start(): Promise<void> {
|
|
103
|
+
if (stopped) return Promise.resolve();
|
|
104
|
+
startup ??= (async () => {
|
|
105
|
+
await store.requeueUnacknowledgedAlerts(sessionId).catch(report);
|
|
106
|
+
if (stopped) return;
|
|
107
|
+
started = true;
|
|
108
|
+
await wake();
|
|
109
|
+
})();
|
|
110
|
+
return startup;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The agent read this run's evidence: its alerts were received and are not offered again. */
|
|
114
|
+
async function received(reference: string): Promise<void> {
|
|
115
|
+
await store.acknowledgeRunAlerts(sessionId, reference).catch(() => undefined);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Hand nothing more over, and wait for a running takeover or drain to finish. */
|
|
119
|
+
async function stop(): Promise<void> {
|
|
120
|
+
stopped = true;
|
|
121
|
+
await startup;
|
|
122
|
+
await current;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { start, wake, received, stop };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type AlertDelivery = ReturnType<typeof createAlertDelivery>;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cited answers through the Responses API (`POST /v1/responses`), with local
|
|
3
|
+
* continuation and stored evidence.
|
|
4
|
+
*
|
|
5
|
+
* The pinned SDK has no Responses resource, so this module issues the documented
|
|
6
|
+
* raw request as a recorded paid call (`paid-call.ts`): never retried, because
|
|
7
|
+
* the API has no native background mode or retrieve-by-id, so an ambiguous
|
|
8
|
+
* outcome is recorded as `uncertain` rather than repeated.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ResearchStore, EvidenceInput, CitationInput } from "./store.ts";
|
|
12
|
+
import { createPaidCalls, UnusableResponse } from "./paid-call.ts";
|
|
13
|
+
import { sourcePolicy } from "./source-policy.ts";
|
|
14
|
+
import { clean, oneLine, RECEIPT_LIMIT, replayReceipt, UNTRUSTED } from "./receipt.ts";
|
|
15
|
+
|
|
16
|
+
/** The part of the Parallel client the answer path needs. */
|
|
17
|
+
export type AnswerClient = {
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
post(path: string, options?: {
|
|
20
|
+
body?: Record<string, unknown>;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
maxRetries?: number;
|
|
23
|
+
timeout?: number;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}): Promise<unknown>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type AnswerInput = {
|
|
29
|
+
question: string;
|
|
30
|
+
effort?: "low" | "medium" | "high";
|
|
31
|
+
instructions?: string;
|
|
32
|
+
include_domains?: string[];
|
|
33
|
+
exclude_domains?: string[];
|
|
34
|
+
/** Local reference of an earlier answer to continue. */
|
|
35
|
+
continue_from?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const ANSWER_EFFORTS = ["low", "medium", "high"] as const;
|
|
39
|
+
/** Parallel's own default; low suits only a fact one good source settles. */
|
|
40
|
+
export const DEFAULT_ANSWER_EFFORT: (typeof ANSWER_EFFORTS)[number] = "medium";
|
|
41
|
+
|
|
42
|
+
export type AnswerReceipt = { text: string; details: Record<string, unknown> };
|
|
43
|
+
|
|
44
|
+
type Options = {
|
|
45
|
+
store: ResearchStore;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
client: () => Promise<AnswerClient>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type Annotation = { type?: string; url?: string; title?: string; start_index?: number; end_index?: number };
|
|
51
|
+
|
|
52
|
+
/** Read the answer text: `output_text` when present, otherwise the message items. */
|
|
53
|
+
function answerText(response: Record<string, unknown>): string {
|
|
54
|
+
if (typeof response.output_text === "string" && response.output_text.trim()) return response.output_text;
|
|
55
|
+
const output = response.output;
|
|
56
|
+
if (!Array.isArray(output)) return "";
|
|
57
|
+
const parts: string[] = [];
|
|
58
|
+
for (const item of output) {
|
|
59
|
+
if (!item || typeof item !== "object") continue;
|
|
60
|
+
const content = (item as { content?: unknown }).content;
|
|
61
|
+
if (!Array.isArray(content)) continue;
|
|
62
|
+
for (const part of content) {
|
|
63
|
+
if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") {
|
|
64
|
+
parts.push((part as { text: string }).text);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return parts.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function annotations(response: Record<string, unknown>): Annotation[] {
|
|
72
|
+
const output = response.output;
|
|
73
|
+
if (!Array.isArray(output)) return [];
|
|
74
|
+
const found: Annotation[] = [];
|
|
75
|
+
for (const item of output) {
|
|
76
|
+
if (!item || typeof item !== "object") continue;
|
|
77
|
+
const content = (item as { content?: unknown }).content;
|
|
78
|
+
if (!Array.isArray(content)) continue;
|
|
79
|
+
for (const part of content) {
|
|
80
|
+
if (!part || typeof part !== "object") continue;
|
|
81
|
+
const list = (part as { annotations?: unknown }).annotations;
|
|
82
|
+
if (!Array.isArray(list)) continue;
|
|
83
|
+
for (const annotation of list) {
|
|
84
|
+
if (annotation && typeof annotation === "object" && (annotation as Annotation).type === "url_citation") {
|
|
85
|
+
found.push(annotation as Annotation);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function evidenceFromAnswer(text: string, list: Annotation[]): { evidence: EvidenceInput[]; citations: CitationInput[] } {
|
|
94
|
+
const evidence: EvidenceInput[] = [{
|
|
95
|
+
kind: "report",
|
|
96
|
+
origin: "provider_report",
|
|
97
|
+
title: "Cited answer",
|
|
98
|
+
body: text,
|
|
99
|
+
pointer: "output_text",
|
|
100
|
+
ref: "answer",
|
|
101
|
+
}];
|
|
102
|
+
const citations: CitationInput[] = [];
|
|
103
|
+
const seen = new Map<string, string>();
|
|
104
|
+
for (const annotation of list) {
|
|
105
|
+
const url = clean(annotation.url);
|
|
106
|
+
if (!url) continue;
|
|
107
|
+
let ref = seen.get(url);
|
|
108
|
+
if (!ref) {
|
|
109
|
+
ref = `src:${seen.size}`;
|
|
110
|
+
seen.set(url, ref);
|
|
111
|
+
evidence.push({
|
|
112
|
+
kind: "source",
|
|
113
|
+
origin: "provider_source",
|
|
114
|
+
title: clean(annotation.title),
|
|
115
|
+
url,
|
|
116
|
+
pointer: "annotations",
|
|
117
|
+
body: url,
|
|
118
|
+
payload: annotation,
|
|
119
|
+
ref,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const start = typeof annotation.start_index === "number" ? annotation.start_index : undefined;
|
|
123
|
+
const end = typeof annotation.end_index === "number" ? annotation.end_index : undefined;
|
|
124
|
+
const claim = start !== undefined && end !== undefined && end > start ? text.slice(start, end) : undefined;
|
|
125
|
+
citations.push({ evidence: "answer", source: ref, url, title: clean(annotation.title), claim: claim ? oneLine(claim, 300) : undefined });
|
|
126
|
+
}
|
|
127
|
+
return { evidence, citations };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The answer text for the receipt, cut at the budget with a pointer to the rest. */
|
|
131
|
+
function answerPreview(text: string, limit = RECEIPT_LIMIT): { text: string; truncated: boolean } {
|
|
132
|
+
if (text.length <= limit) return { text, truncated: false };
|
|
133
|
+
return { text: `${text.slice(0, limit)}\n\n[Answer continues; read the rest with web_recall { handle }]`, truncated: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createAnswerService({ store, sessionId, client }: Options) {
|
|
137
|
+
const calls = createPaidCalls({ store, sessionId, client });
|
|
138
|
+
|
|
139
|
+
async function continuationId(reference: string): Promise<string> {
|
|
140
|
+
const run = await store.run(sessionId, reference);
|
|
141
|
+
if (!run) throw new Error(`${reference} is not a research record in this session`);
|
|
142
|
+
if (run.kind !== "answer") throw new Error(`${reference} is not an earlier web_answer; continue a web_research run with web_research { continue_from }`);
|
|
143
|
+
if (!run.provider_run_id) throw new Error(`${reference} has no completed answer to continue from (state: ${run.state})`);
|
|
144
|
+
return run.provider_run_id;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function answer(input: AnswerInput, call: { invocationId: string; signal?: AbortSignal }): Promise<AnswerReceipt> {
|
|
148
|
+
const question = clean(input.question);
|
|
149
|
+
if (!question) throw new Error("question must be a non-empty string");
|
|
150
|
+
const effort = input.effort ?? DEFAULT_ANSWER_EFFORT;
|
|
151
|
+
if (!(ANSWER_EFFORTS as readonly string[]).includes(effort)) throw new Error("effort must be low, medium, or high");
|
|
152
|
+
const previous = input.continue_from ? await continuationId(clean(input.continue_from)!) : undefined;
|
|
153
|
+
const { include, exclude } = sourcePolicy(input);
|
|
154
|
+
|
|
155
|
+
const intent = {
|
|
156
|
+
kind: "answer" as const,
|
|
157
|
+
question,
|
|
158
|
+
effort,
|
|
159
|
+
instructions: clean(input.instructions),
|
|
160
|
+
include_domains: include,
|
|
161
|
+
exclude_domains: exclude,
|
|
162
|
+
continue_from: clean(input.continue_from),
|
|
163
|
+
};
|
|
164
|
+
const body: Record<string, unknown> = {
|
|
165
|
+
model: "parallel",
|
|
166
|
+
input: question,
|
|
167
|
+
reasoning: { effort },
|
|
168
|
+
...(previous ? { previous_response_id: previous } : {}),
|
|
169
|
+
...(clean(input.instructions) ? { instructions: clean(input.instructions) } : {}),
|
|
170
|
+
...(include.length || exclude.length
|
|
171
|
+
? { tools: [{ type: "web_search", filters: { allowed_domains: include, blocked_domains: exclude } }] }
|
|
172
|
+
: {}),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const outcome = await calls.run({
|
|
176
|
+
kind: "answer",
|
|
177
|
+
label: "Answer",
|
|
178
|
+
intent,
|
|
179
|
+
invocation: call,
|
|
180
|
+
uncertainAdvice: reference => `Its outcome is unknown (record ${reference}); ask again only if you still need the answer.`,
|
|
181
|
+
send: async (parallel, { options }) => await parallel.post("/v1/responses", {
|
|
182
|
+
body,
|
|
183
|
+
...(parallel.apiKey ? { headers: { Authorization: `Bearer ${parallel.apiKey}` } } : {}),
|
|
184
|
+
...options,
|
|
185
|
+
}) as Record<string, unknown>,
|
|
186
|
+
settle: (response, reference) => {
|
|
187
|
+
const id = typeof response.id === "string" ? response.id : undefined;
|
|
188
|
+
if (!id) {
|
|
189
|
+
throw new UnusableResponse("uncertain", "response carried no id", `The answer came back incomplete (record ${reference}); ask again if you still need it.`);
|
|
190
|
+
}
|
|
191
|
+
const text = answerText(response);
|
|
192
|
+
if (!text.trim()) {
|
|
193
|
+
throw new UnusableResponse("failed", "response carried no answer text", `The answer came back empty (record ${reference}).`);
|
|
194
|
+
}
|
|
195
|
+
const { evidence, citations } = evidenceFromAnswer(text, annotations(response));
|
|
196
|
+
return { summary: oneLine(text, 120), providerRunId: id, rawResult: response, evidence, citations };
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (outcome.replayed) {
|
|
201
|
+
const { reference, run, hits } = outcome;
|
|
202
|
+
const first = hits.find(hit => hit.kind === "report") ?? hits[0];
|
|
203
|
+
// The stored answer is the body; leave room for the header and footer within the receipt budget.
|
|
204
|
+
const stored = first ? (await store.readEvidence(sessionId, first.handle, { limit: RECEIPT_LIMIT - 800 })).text : "";
|
|
205
|
+
const { text, details } = replayReceipt({ noun: "question", reference, state: run?.state ?? "unknown", hits, body: stored ? [stored] : [], listHits: !stored });
|
|
206
|
+
return { text, details: { ...details, handle: first?.handle } };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { reference, settlement } = outcome;
|
|
210
|
+
const text = settlement?.evidence?.find(item => item.kind === "report")?.body ?? "";
|
|
211
|
+
const citations = settlement?.citations ?? [];
|
|
212
|
+
const report = outcome.evidence.find(item => item.kind === "report")?.handle;
|
|
213
|
+
const preview = answerPreview(text);
|
|
214
|
+
return {
|
|
215
|
+
text: [
|
|
216
|
+
preview.text,
|
|
217
|
+
"",
|
|
218
|
+
`Citations: ${citations.length} source link(s). Full answer: ${reference}.`,
|
|
219
|
+
`Ask a follow-up with web_answer { question, continue_from: "${reference}" }.`,
|
|
220
|
+
report ? `Read the stored answer with web_recall { handle: "${report}" }.` : undefined,
|
|
221
|
+
UNTRUSTED,
|
|
222
|
+
].filter((line): line is string => line !== undefined).join("\n"),
|
|
223
|
+
details: {
|
|
224
|
+
reference,
|
|
225
|
+
handle: report,
|
|
226
|
+
sources: citations.length,
|
|
227
|
+
truncated: preview.truncated,
|
|
228
|
+
effort,
|
|
229
|
+
continued: previous !== undefined,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { answer };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type AnswerService = ReturnType<typeof createAnswerService>;
|