Compare commits
17 Commits
906b40a753
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dfe90de75 | |||
| a7bb033eb8 | |||
| 1e5f7b8ff2 | |||
| 367ebc1c7f | |||
| bc56546b49 | |||
| 9b3a205917 | |||
| ecb9410c5c | |||
| bb3d7d399c | |||
| c55ae2faa5 | |||
| 75e6123aba | |||
| 7b52cef2e6 | |||
| fe42ba5b38 | |||
| 346c85473d | |||
| e32c1491b5 | |||
| 5dc40fee33 | |||
| 2d0b2294cd | |||
| 37eb243d26 |
@@ -1,291 +0,0 @@
|
||||
# GitHub handles approved to bypass contribution auto-close
|
||||
# Format: <username> <capability>
|
||||
# capability:
|
||||
# issue future issues stay open
|
||||
# pr future issues and PRs stay open
|
||||
|
||||
herrnel pr
|
||||
julien-c pr
|
||||
barapa pr
|
||||
alasano pr
|
||||
aadishv pr
|
||||
airtonix pr
|
||||
aliou pr
|
||||
aos pr
|
||||
austinm911 pr
|
||||
banteg pr
|
||||
ben-vargas pr
|
||||
butelo pr
|
||||
can1357 pr
|
||||
CarlosGtrz pr
|
||||
cau1k pr
|
||||
cmf pr
|
||||
crcatala pr
|
||||
Cursivez pr
|
||||
cv pr
|
||||
dannote pr
|
||||
default-anton pr
|
||||
dnouri pr
|
||||
DronNick pr
|
||||
enisdenjo pr
|
||||
ferologics pr
|
||||
fightbulc pr
|
||||
ghoulr pr
|
||||
gnattu pr
|
||||
HACKE-RC pr
|
||||
hewliyang pr
|
||||
hjanuschka pr
|
||||
iamd3vil pr
|
||||
jblwilliams pr
|
||||
joshp123 pr
|
||||
jsinge97 pr
|
||||
justram pr
|
||||
kaofelix pr
|
||||
kiliman pr
|
||||
kim0 pr
|
||||
lockmeister pr
|
||||
LukeFost pr
|
||||
lukele pr
|
||||
m-box-mr pr
|
||||
marckrenn pr
|
||||
markusylisiurunen pr
|
||||
mcinteerj pr
|
||||
melihmucuk pr
|
||||
mitsuhiko pr
|
||||
mrexodia pr
|
||||
nathyong pr
|
||||
nickseelert pr
|
||||
nicobailon pr
|
||||
ninlds pr
|
||||
ogulcancelik pr
|
||||
patrick-kidger pr
|
||||
paulbettner pr
|
||||
Perlence pr
|
||||
pjtf93 pr
|
||||
prateekmedia pr
|
||||
prathamdby pr
|
||||
ribelo pr
|
||||
richardgill pr
|
||||
robinwander pr
|
||||
ronyrus pr
|
||||
roshanasingh4 pr
|
||||
scutifer pr
|
||||
skuridin pr
|
||||
steipete pr
|
||||
svkozak pr
|
||||
tallshort pr
|
||||
theBucky pr
|
||||
thomasmhr pr
|
||||
tiagoefreitas pr
|
||||
timolins pr
|
||||
tmustier pr
|
||||
tudoroancea pr
|
||||
unexge pr
|
||||
vaayne pr
|
||||
VaclavSynacek pr
|
||||
vsabavat pr
|
||||
w-winter pr
|
||||
Whamp pr
|
||||
WismutHansen pr
|
||||
XesGaDeus pr
|
||||
yevhen pr
|
||||
badlogictest pr
|
||||
terrorobe pr
|
||||
zedrdave pr
|
||||
mrud pr
|
||||
toorusr pr
|
||||
andresaraujo pr
|
||||
lightningRalf pr
|
||||
williballenthin pr
|
||||
masonc15 pr
|
||||
4h9fbZ pr
|
||||
haoqixu pr
|
||||
Graffioh pr
|
||||
charles-cooper pr
|
||||
emanuelst pr
|
||||
juanibiapina pr
|
||||
liby pr
|
||||
pasky pr
|
||||
odysseus0 pr
|
||||
giuseppeg pr
|
||||
michaelpersonal pr
|
||||
academo pr
|
||||
PriNova pr
|
||||
semtexzv pr
|
||||
jasonish pr
|
||||
markusn pr
|
||||
SamFold pr
|
||||
Soleone pr
|
||||
virtuald pr
|
||||
NateSmyth pr
|
||||
7Sageer pr
|
||||
MatthieuBizien pr
|
||||
sumeet pr
|
||||
marchellodev pr
|
||||
vedang pr
|
||||
lucemia pr
|
||||
mcollina pr
|
||||
lajarre pr
|
||||
smithbm2316 pr
|
||||
drewburr pr
|
||||
gordonhwc pr
|
||||
deybhayden pr
|
||||
tintinweb pr
|
||||
asoules pr
|
||||
zhahaoyu pr
|
||||
in0vik pr
|
||||
jtac pr
|
||||
yzhg1983 pr
|
||||
smcllns pr
|
||||
dmmulroy pr
|
||||
zmberber pr
|
||||
andresvi94 pr
|
||||
sudosubin pr
|
||||
Mic92 pr
|
||||
pmateusz pr
|
||||
wirjo pr
|
||||
jay-aye-see-kay pr
|
||||
lucasmeijer pr
|
||||
Evizero pr
|
||||
|
||||
ofa1 pr
|
||||
|
||||
crisog issue
|
||||
|
||||
mpazik pr
|
||||
|
||||
vekexasia pr
|
||||
|
||||
Michaelliv pr
|
||||
|
||||
cmraible pr
|
||||
|
||||
dljsjr pr
|
||||
|
||||
drio pr
|
||||
|
||||
jlaneve pr
|
||||
|
||||
tantara pr
|
||||
|
||||
Nutlope pr
|
||||
|
||||
xl0 pr
|
||||
|
||||
mdsjip pr
|
||||
|
||||
Exrun94 pr
|
||||
|
||||
marcbloech pr
|
||||
|
||||
pidalf pr
|
||||
|
||||
injaneity pr
|
||||
|
||||
thirtythreeforty pr
|
||||
|
||||
justinpbarnett pr
|
||||
|
||||
cristinaponcela pr
|
||||
|
||||
LooSik pr
|
||||
|
||||
mchenco pr
|
||||
|
||||
Phoen1xCode pr
|
||||
|
||||
louis030195 pr
|
||||
|
||||
technocidal pr
|
||||
|
||||
pandada8 pr
|
||||
|
||||
npupko issue
|
||||
|
||||
chrisvariety pr
|
||||
|
||||
maximilianzuern pr
|
||||
|
||||
brianmichel pr
|
||||
|
||||
abhinavmathur-atlan pr
|
||||
|
||||
mattiacerutti pr
|
||||
|
||||
josephyoung pr
|
||||
|
||||
mbazso pr
|
||||
|
||||
AJM10565 pr
|
||||
|
||||
DanielThomas pr
|
||||
|
||||
MichaelYochpaz pr
|
||||
|
||||
stephanmck pr
|
||||
|
||||
rolfvreijdenberger pr
|
||||
|
||||
psoukie pr
|
||||
|
||||
vastxie pr
|
||||
|
||||
ItsumoSeito pr
|
||||
|
||||
davidlifschitz pr
|
||||
|
||||
vdxz pr
|
||||
|
||||
dangooddd pr
|
||||
|
||||
Mearman pr
|
||||
|
||||
dodiego pr
|
||||
|
||||
any-victor pr
|
||||
|
||||
geraschenko pr
|
||||
|
||||
skhoroshavin pr
|
||||
|
||||
cyzlmh pr
|
||||
|
||||
xz-dev pr
|
||||
|
||||
rajp152k pr
|
||||
|
||||
affanali2k3 pr
|
||||
|
||||
ArcadiaLin pr
|
||||
|
||||
anilgulecha pr
|
||||
|
||||
DeviosLang pr
|
||||
|
||||
HarrodRen pr
|
||||
|
||||
aaronkyriesenbach pr
|
||||
|
||||
farid-fari pr
|
||||
|
||||
petrroll pr
|
||||
|
||||
vibeinging pr
|
||||
|
||||
DivineDominion pr
|
||||
|
||||
ananthakumaran pr
|
||||
|
||||
andrebreijao pr
|
||||
|
||||
anh-chu pr
|
||||
|
||||
rsaryev pr
|
||||
|
||||
QuintinShaw pr
|
||||
|
||||
R-Taneja pr
|
||||
|
||||
zaycruz pr
|
||||
|
||||
mteam88 pr
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Bug Report
|
||||
description: Report something that's broken
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md).
|
||||
|
||||
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
**Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Be specific. Include error messages if any.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps to trigger the bug.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: e.g. 0.49.0
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://discord.com/invite/3cU7Bz4UPx
|
||||
about: Ask questions on Discord instead of opening an issue
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Contribution Proposal
|
||||
description: Propose a change or feature (required for new contributors before submitting a PR)
|
||||
labels: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md).
|
||||
|
||||
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want to change?
|
||||
description: Be specific and concise.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why?
|
||||
description: What problem does this solve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How? (optional)
|
||||
description: Brief technical approach if you have one in mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Package Report
|
||||
description: Report a problematic Pi package listed on pi.dev
|
||||
labels: ["package-report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead.
|
||||
|
||||
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
- type: input
|
||||
id: package-name
|
||||
attributes:
|
||||
label: Package name
|
||||
description: The npm package name from pi.dev.
|
||||
placeholder: "@scope/package"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: package-version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The package version shown on pi.dev.
|
||||
placeholder: "0.1.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: report-type
|
||||
attributes:
|
||||
label: What are you reporting?
|
||||
options:
|
||||
- Malicious or unsafe behavior
|
||||
- Impersonation
|
||||
- Trademark / TOS Violations
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details
|
||||
description: Describe the concern and include links, logs, or screenshots if helpful.
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,179 +0,0 @@
|
||||
name: Approve Contributor
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Update contributor approval
|
||||
id: update
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
const commenter = context.payload.comment.user.login;
|
||||
const commentBody = (context.payload.comment.body || '').trim();
|
||||
|
||||
let targetCapability;
|
||||
if (/\blgtmi\b/i.test(commentBody)) {
|
||||
targetCapability = 'issue';
|
||||
} else if (/\blgtm\b/i.test(commentBody)) {
|
||||
targetCapability = 'pr';
|
||||
} else {
|
||||
console.log('Comment does not match lgtm or lgtmi');
|
||||
core.setOutput('status', 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: commenter,
|
||||
});
|
||||
|
||||
if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) {
|
||||
console.log(`${commenter} does not have write access`);
|
||||
core.setOutput('status', 'skipped');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.log(`${commenter} does not have collaborator access`);
|
||||
core.setOutput('status', 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
function parseApprovedUsers(content) {
|
||||
const lines = content.split('\n');
|
||||
const entries = [];
|
||||
const users = new Map();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
entries.push({ type: 'other', line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length !== 2) {
|
||||
console.log(`Skipping malformed line: ${line}`);
|
||||
entries.push({ type: 'other', line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const [username, capability] = parts;
|
||||
const normalizedCapability = capability.toLowerCase();
|
||||
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
||||
console.log(`Skipping line with invalid capability: ${line}`);
|
||||
entries.push({ type: 'other', line });
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedUser = username.toLowerCase();
|
||||
const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability };
|
||||
entries.push(entry);
|
||||
users.set(normalizedUser, entry);
|
||||
}
|
||||
|
||||
return { entries, users };
|
||||
}
|
||||
|
||||
function stringifyApprovedUsers(entries) {
|
||||
const normalizedEntries = [...entries];
|
||||
|
||||
while (normalizedEntries.length > 0) {
|
||||
const lastEntry = normalizedEntries[normalizedEntries.length - 1];
|
||||
if (lastEntry.type !== 'other' || lastEntry.line.trim() !== '') {
|
||||
break;
|
||||
}
|
||||
normalizedEntries.pop();
|
||||
}
|
||||
|
||||
return `${normalizedEntries
|
||||
.map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line))
|
||||
.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(APPROVED_FILE, 'utf8');
|
||||
const { entries, users } = parseApprovedUsers(content);
|
||||
const normalizedAuthor = issueAuthor.toLowerCase();
|
||||
const existingEntry = users.get(normalizedAuthor);
|
||||
const existingCapability = existingEntry?.capability ?? null;
|
||||
|
||||
if (existingCapability === 'pr' || existingCapability === targetCapability) {
|
||||
core.setOutput('status', 'already');
|
||||
core.setOutput('capability', existingCapability);
|
||||
console.log(`${issueAuthor} is already approved for ${existingCapability}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingEntry) {
|
||||
existingEntry.capability = targetCapability;
|
||||
} else {
|
||||
entries.push({ type: 'user', username: issueAuthor, normalizedUser: normalizedAuthor, capability: targetCapability });
|
||||
}
|
||||
|
||||
fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries));
|
||||
core.setOutput('status', existingCapability ? 'updated' : 'added');
|
||||
core.setOutput('capability', targetCapability);
|
||||
console.log(`Set ${issueAuthor} capability to ${targetCapability}`);
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .github/APPROVED_CONTRIBUTORS
|
||||
git diff --staged --quiet || git commit -m "chore: approve contributor ${{ github.event.issue.user.login }}"
|
||||
git push
|
||||
|
||||
- name: Comment on issue
|
||||
if: steps.update.outputs.status == 'added' || steps.update.outputs.status == 'updated' || steps.update.outputs.status == 'already'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
const capability = '${{ steps.update.outputs.capability }}';
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
let body;
|
||||
|
||||
if ('${{ steps.update.outputs.status }}' === 'already') {
|
||||
body = `@${issueAuthor} is already approved.`;
|
||||
} else if (capability === 'issue') {
|
||||
body = [
|
||||
`@${issueAuthor} approved for issues. Your future issues will not be auto-closed. PRs still require \`lgtm\`.`,
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
||||
].join('\n');
|
||||
} else {
|
||||
body = [
|
||||
`@${issueAuthor} approved for issues and PRs. Your future issues and PRs will not be auto-closed.`,
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
name: Build Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to build (e.g., v0.12.0)'
|
||||
required: true
|
||||
type: string
|
||||
source_ref:
|
||||
description: 'Source ref to build/publish (defaults to tag; use only for release recovery)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: build-binaries-${{ github.event.inputs.tag || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Keep the public GitHub Release publication last. Binary assets are staged in
|
||||
# a draft release first; cleanup removes the draft if later publishing fails.
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Create source archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
mkdir -p release-assets
|
||||
./scripts/create-source-archive.sh \
|
||||
--version "${VERSION}" \
|
||||
--ref HEAD \
|
||||
--out "release-assets/pi-${VERSION}-source.tar.gz"
|
||||
|
||||
- name: Build binaries from source archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
build_root="$(mktemp -d)"
|
||||
trap 'rm -rf "${build_root}"' EXIT
|
||||
tar -xzf "release-assets/pi-${VERSION}-source.tar.gz" -C "${build_root}"
|
||||
"${build_root}/pi-${VERSION}/scripts/build-binaries.sh" \
|
||||
--out "${GITHUB_WORKSPACE}/packages/coding-agent/binaries"
|
||||
|
||||
- name: Prepare GitHub release payload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p release-assets
|
||||
|
||||
VERSION="${RELEASE_TAG}"
|
||||
VERSION="${VERSION#v}" # Remove 'v' prefix
|
||||
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out release-assets/RELEASE_NOTES.md
|
||||
node scripts/generate-coding-agent-install-lock.mjs --check
|
||||
cp packages/coding-agent/install-lock/package.json release-assets/pi-coding-agent-install-package.json
|
||||
cp packages/coding-agent/install-lock/package-lock.json release-assets/pi-coding-agent-install-package-lock.json
|
||||
|
||||
cd packages/coding-agent/binaries
|
||||
|
||||
binary_assets=(
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-x64.tar.gz
|
||||
pi-linux-x64.tar.gz
|
||||
pi-linux-arm64.tar.gz
|
||||
pi-windows-x64.zip
|
||||
pi-windows-arm64.zip
|
||||
)
|
||||
|
||||
for asset in "${binary_assets[@]}"; do
|
||||
test -f "${asset}"
|
||||
done
|
||||
|
||||
cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/"
|
||||
|
||||
cd "${GITHUB_WORKSPACE}/release-assets"
|
||||
source_asset="pi-${VERSION}-source.tar.gz"
|
||||
release_assets=(
|
||||
"${source_asset}"
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-x64.tar.gz
|
||||
pi-linux-x64.tar.gz
|
||||
pi-linux-arm64.tar.gz
|
||||
pi-windows-x64.zip
|
||||
pi-windows-arm64.zip
|
||||
pi-coding-agent-install-package.json
|
||||
pi-coding-agent-install-package-lock.json
|
||||
)
|
||||
sha256sum "${release_assets[@]}" > SHA256SUMS
|
||||
|
||||
- name: Upload GitHub release payload
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: release-assets-${{ env.RELEASE_TAG }}
|
||||
path: release-assets/*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
stage-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Download GitHub release payload
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: release-assets-${{ env.RELEASE_TAG }}
|
||||
path: release-assets
|
||||
|
||||
- name: Validate GitHub release payload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd release-assets
|
||||
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
source_asset="pi-${VERSION}-source.tar.gz"
|
||||
expected_assets=(
|
||||
"${source_asset}"
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-x64.tar.gz
|
||||
pi-linux-x64.tar.gz
|
||||
pi-linux-arm64.tar.gz
|
||||
pi-windows-x64.zip
|
||||
pi-windows-arm64.zip
|
||||
pi-coding-agent-install-package.json
|
||||
pi-coding-agent-install-package-lock.json
|
||||
SHA256SUMS
|
||||
RELEASE_NOTES.md
|
||||
)
|
||||
|
||||
for asset in "${expected_assets[@]}"; do
|
||||
test -f "${asset}"
|
||||
done
|
||||
|
||||
sha256sum -c SHA256SUMS
|
||||
|
||||
- name: Create draft GitHub Release and upload assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd release-assets
|
||||
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
source_asset="pi-${VERSION}-source.tar.gz"
|
||||
release_assets=(
|
||||
"${source_asset}"
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-x64.tar.gz
|
||||
pi-linux-x64.tar.gz
|
||||
pi-linux-arm64.tar.gz
|
||||
pi-windows-x64.zip
|
||||
pi-windows-arm64.zip
|
||||
pi-coding-agent-install-package.json
|
||||
pi-coding-agent-install-package-lock.json
|
||||
SHA256SUMS
|
||||
)
|
||||
|
||||
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
|
||||
if [[ "${existing_release}" == "false" ]]; then
|
||||
echo "::error::GitHub Release ${RELEASE_TAG} is already published. Refusing to mutate a public release."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${existing_release}" == "true" ]]; then
|
||||
gh release delete "${RELEASE_TAG}" --yes
|
||||
fi
|
||||
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--verify-tag \
|
||||
--draft \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file RELEASE_NOTES.md \
|
||||
"${release_assets[@]}"
|
||||
|
||||
expected_asset_names="$(printf '%s\n' "${release_assets[@]}" | sort)"
|
||||
actual_asset_names="$(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)"
|
||||
|
||||
if [[ "${actual_asset_names}" != "${expected_asset_names}" ]]; then
|
||||
echo "::error::Draft GitHub Release asset set does not match expected files."
|
||||
diff -u <(printf '%s\n' "${expected_asset_names}") <(printf '%s\n' "${actual_asset_names}") || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: stage-github-release
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: npm
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
|
||||
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Upgrade npm for trusted publishing
|
||||
run: |
|
||||
npm install -g npm@11.16.0 --ignore-scripts
|
||||
npm --version
|
||||
|
||||
- name: Publish npm packages
|
||||
run: node scripts/publish.mjs
|
||||
|
||||
publish-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- stage-github-release
|
||||
- publish-npm
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Publish staged GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
|
||||
if [[ "${existing_release}" == "" ]]; then
|
||||
echo "::error::Draft GitHub Release ${RELEASE_TAG} does not exist."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${existing_release}" == "false" ]]; then
|
||||
echo "::error::GitHub Release ${RELEASE_TAG} is already published."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh release edit "${RELEASE_TAG}" --draft=false
|
||||
|
||||
cleanup-draft-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
- stage-github-release
|
||||
- publish-npm
|
||||
- publish-github-release
|
||||
if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.publish-github-release.result != 'success') }}
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Delete draft GitHub Release after failure
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)"
|
||||
if [[ "${existing_release}" == "true" ]]; then
|
||||
gh release delete "${RELEASE_TAG}" --yes
|
||||
fi
|
||||
@@ -1,42 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-check-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
|
||||
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
@@ -1,634 +0,0 @@
|
||||
# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added
|
||||
# or when a staff member comments `@issuron analyze` anywhere on an issue.
|
||||
#
|
||||
# Comment triggers can include one `#run-on-*` tag anywhere in the text:
|
||||
# @issuron analyze #run-on-linux -> ubuntu-latest (default)
|
||||
# @issuron analyze #run-on-windows -> windows-latest
|
||||
# @issuron analyze #run-on-mac -> macos-latest
|
||||
#
|
||||
# Label triggers always run on the default Linux runner. Runner selection is
|
||||
# intentionally restricted to hardcoded aliases in the authorization step.
|
||||
#
|
||||
# Setup required before this works:
|
||||
# 1. Create a `pi-analyze` GitHub environment on the repo and add a
|
||||
# `PI_AUTH_JSON` secret containing the contents of a pi auth.json
|
||||
# (~/.pi/agent/auth.json).
|
||||
# 2. Create the `pi-analyze` label.
|
||||
# 3. Add a repository secret `EARENDIL_ORG_READ_TOKEN` with permission to
|
||||
# read `earendil-works` org membership. The authorization job uses it to
|
||||
# verify that the label actor is an active member of `earendil-works/staff`.
|
||||
# 4. Add an environment secret `PI_GIST_TOKEN` on `pi-analyze` with gist
|
||||
# creation permission. The analysis job uses it to upload the exported
|
||||
# session gist.
|
||||
# 5. Add an environment secret `PI_AUTH_UPDATE_TOKEN` on `pi-analyze` with
|
||||
# permission to update this repo's environment secrets. The analysis job
|
||||
# uses it to write back refreshed `PI_AUTH_JSON` contents.
|
||||
#
|
||||
# The selected runner must have Node.js support plus gh, fd, and ripgrep. GitHub
|
||||
# hosted runners are bootstrapped below; future self-hosted aliases should have
|
||||
# those dependencies preinstalled or installable by the setup steps.
|
||||
#
|
||||
# The session runs in a high-entropy checkout directory so the recorded cwd is
|
||||
# a unique string. Import the session into a local checkout with the
|
||||
# /ir extension command (.pi/extensions/import-repro.ts):
|
||||
# pi "/ir <gist-id | gist-url | pi.dev/session URL>"
|
||||
|
||||
name: Issue Analysis
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-analysis-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.verify.outputs.should_run }}
|
||||
extra_instructions: ${{ steps.verify.outputs.extra_instructions }}
|
||||
runs_on: ${{ steps.verify.outputs.runs_on }}
|
||||
runner_os: ${{ steps.verify.outputs.runner_os }}
|
||||
runner_profile: ${{ steps.verify.outputs.runner_profile }}
|
||||
steps:
|
||||
- name: Verify sender permission
|
||||
id: verify
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }}
|
||||
with:
|
||||
script: |
|
||||
const ANALYZE_LABEL = 'pi-analyze';
|
||||
const TRIGGER_RE = /@issuron\s+analyze\b/i;
|
||||
const RUN_ON_TAG_RE = /#run-on-([a-z0-9][a-z0-9_-]*)\b/gi;
|
||||
const RUNNER_PROFILES = {
|
||||
linux: { runsOn: 'ubuntu-latest', os: 'linux' },
|
||||
windows: { runsOn: 'windows-latest', os: 'windows' },
|
||||
mac: { runsOn: 'macos-latest', os: 'macos' },
|
||||
};
|
||||
const RUN_ON_ALIASES = {
|
||||
linux: 'linux',
|
||||
ubuntu: 'linux',
|
||||
'ubuntu-latest': 'linux',
|
||||
windows: 'windows',
|
||||
win: 'windows',
|
||||
'windows-latest': 'windows',
|
||||
mac: 'mac',
|
||||
macos: 'mac',
|
||||
darwin: 'mac',
|
||||
'macos-latest': 'mac',
|
||||
};
|
||||
|
||||
const username = context.payload.sender.login;
|
||||
let extraInstructions = '';
|
||||
let runnerProfile = 'linux';
|
||||
|
||||
core.setOutput('should_run', 'false');
|
||||
core.setOutput('extra_instructions', '');
|
||||
core.setOutput('runs_on', JSON.stringify(RUNNER_PROFILES.linux.runsOn));
|
||||
core.setOutput('runner_os', RUNNER_PROFILES.linux.os);
|
||||
core.setOutput('runner_profile', runnerProfile);
|
||||
|
||||
if (context.eventName === 'issues') {
|
||||
if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) {
|
||||
console.log('Not a pi-analyze label event');
|
||||
return;
|
||||
}
|
||||
} else if (context.eventName === 'issue_comment') {
|
||||
if (context.payload.issue.pull_request) {
|
||||
console.log('Ignoring pull request comment');
|
||||
return;
|
||||
}
|
||||
const body = context.payload.comment.body || '';
|
||||
if (!TRIGGER_RE.test(body)) {
|
||||
console.log('Comment does not contain an @issuron analyze trigger');
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedProfiles = new Set();
|
||||
const unknownTags = [];
|
||||
for (const match of body.matchAll(RUN_ON_TAG_RE)) {
|
||||
const tag = match[1].toLowerCase();
|
||||
const resolved = RUN_ON_ALIASES[tag];
|
||||
if (!resolved) {
|
||||
unknownTags.push(tag);
|
||||
} else {
|
||||
resolvedProfiles.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownTags.length > 0) {
|
||||
core.setFailed(`Unknown issue analysis runner tag(s): ${unknownTags.map((tag) => `#run-on-${tag}`).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
if (resolvedProfiles.size > 1) {
|
||||
core.setFailed(
|
||||
`Conflicting issue analysis runner tags: ${Array.from(resolvedProfiles)
|
||||
.map((profile) => `#run-on-${profile}`)
|
||||
.join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runnerProfile = Array.from(resolvedProfiles)[0] || 'linux';
|
||||
extraInstructions = body.replace(TRIGGER_RE, ' ').replace(RUN_ON_TAG_RE, ' ').trim();
|
||||
} else {
|
||||
console.log(`Unsupported event: ${context.eventName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
async function removeTriggerLabel() {
|
||||
if (context.eventName !== 'issues') return;
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: ANALYZE_LABEL,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.ORG_READ_TOKEN) {
|
||||
await removeTriggerLabel();
|
||||
core.setFailed('EARENDIL_ORG_READ_TOKEN is not configured; refusing to run issue analysis.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/orgs/earendil-works/teams/staff/memberships/${encodeURIComponent(username)}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${process.env.ORG_READ_TOKEN}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
await removeTriggerLabel();
|
||||
core.setFailed(`@${username} is not an active earendil-works/staff member.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
await removeTriggerLabel();
|
||||
core.setFailed(
|
||||
`Could not verify earendil-works/staff membership for @${username}: HTTP ${response.status} ${body}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const membership = await response.json();
|
||||
if (membership.state !== 'active') {
|
||||
await removeTriggerLabel();
|
||||
core.setFailed(`@${username} is not an active earendil-works/staff member.`);
|
||||
return;
|
||||
}
|
||||
console.log(`earendil-works/staff membership for @${username}: ${membership.state}`);
|
||||
} catch (error) {
|
||||
await removeTriggerLabel();
|
||||
core.setFailed(
|
||||
`Could not verify earendil-works/staff membership for @${username}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
if (!['admin', 'write'].includes(data.permission)) {
|
||||
await removeTriggerLabel();
|
||||
core.setFailed(
|
||||
`@${username} has '${data.permission}' permission; write or admin is required to trigger issue analysis.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.eventName === 'issue_comment') {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [ANALYZE_LABEL],
|
||||
});
|
||||
}
|
||||
|
||||
const profile = RUNNER_PROFILES[runnerProfile];
|
||||
console.log(`Selected issue analysis runner profile: ${runnerProfile} (${JSON.stringify(profile.runsOn)})`);
|
||||
core.setOutput('should_run', 'true');
|
||||
core.setOutput('extra_instructions', extraInstructions);
|
||||
core.setOutput('runs_on', JSON.stringify(profile.runsOn));
|
||||
core.setOutput('runner_os', profile.os);
|
||||
core.setOutput('runner_profile', runnerProfile);
|
||||
|
||||
analyze:
|
||||
needs: authorize
|
||||
if: needs.authorize.outputs.should_run == 'true'
|
||||
runs-on: ${{ fromJSON(needs.authorize.outputs.runs_on) }}
|
||||
environment: pi-analyze
|
||||
timeout-minutes: 45
|
||||
concurrency:
|
||||
group: issue-analysis-pi-auth
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
ISSUE_ANALYSIS_MODEL: openai-codex/gpt-5.5
|
||||
ISSUE_ANALYSIS_THINKING: high
|
||||
steps:
|
||||
- name: Create high-entropy working directory name
|
||||
id: workdir
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const crypto = require('crypto');
|
||||
core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`);
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: ${{ steps.workdir.outputs.name }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: needs.authorize.outputs.runner_os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fd-find ripgrep
|
||||
sudo ln -sf "$(which fdfind)" /usr/local/bin/fd
|
||||
|
||||
- name: Install system dependencies (macOS)
|
||||
if: needs.authorize.outputs.runner_os == 'macos'
|
||||
run: |
|
||||
if ! command -v fd >/dev/null 2>&1; then
|
||||
brew install fd
|
||||
fi
|
||||
if ! command -v rg >/dev/null 2>&1; then
|
||||
brew install ripgrep
|
||||
fi
|
||||
|
||||
- name: Install system dependencies (Windows)
|
||||
if: needs.authorize.outputs.runner_os == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$packages = @()
|
||||
if (-not (Get-Command fd -ErrorAction SilentlyContinue)) {
|
||||
$packages += "fd"
|
||||
}
|
||||
if (-not (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||||
$packages += "ripgrep"
|
||||
}
|
||||
if ($packages.Count -gt 0) {
|
||||
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
|
||||
throw "fd and ripgrep must be installed on Windows runners, or Chocolatey must be available to install them."
|
||||
}
|
||||
choco install $packages -y --no-progress
|
||||
}
|
||||
fd --version
|
||||
rg --version
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ steps.workdir.outputs.name }}
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
working-directory: ${{ steps.workdir.outputs.name }}
|
||||
run: npm run build
|
||||
|
||||
- name: Write auth.json
|
||||
id: write_auth
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const authJson = process.env.PI_AUTH_JSON;
|
||||
if (!authJson) {
|
||||
throw new Error('PI_AUTH_JSON secret is not configured for the pi-analyze environment');
|
||||
}
|
||||
|
||||
const agentDir = path.join(process.env.RUNNER_TEMP, 'pi-agent');
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
const authPath = path.join(agentDir, 'auth.json');
|
||||
fs.writeFileSync(authPath, authJson, { mode: 0o600 });
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(authPath, 0o600);
|
||||
}
|
||||
|
||||
- name: Run pi /is
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }}
|
||||
WORKDIR: ${{ steps.workdir.outputs.name }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR);
|
||||
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
||||
const sessionDir = path.join(outDir, 'session');
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
let prompt = `/is ${process.env.ISSUE_URL}`;
|
||||
if (process.env.EXTRA_INSTRUCTIONS) {
|
||||
prompt += '\n\nAdditional instructions from @issuron analyze comment:\n';
|
||||
prompt += process.env.EXTRA_INSTRUCTIONS;
|
||||
}
|
||||
|
||||
const outputPath = path.join(outDir, 'output.md');
|
||||
const output = fs.createWriteStream(outputPath);
|
||||
const args = [
|
||||
'packages/coding-agent/src/cli.ts',
|
||||
'-p',
|
||||
'--approve',
|
||||
'--session-dir',
|
||||
sessionDir,
|
||||
'--model',
|
||||
process.env.ISSUE_ANALYSIS_MODEL,
|
||||
'--thinking',
|
||||
process.env.ISSUE_ANALYSIS_THINKING,
|
||||
prompt,
|
||||
];
|
||||
|
||||
const exitCode = await new Promise((resolve, reject) => {
|
||||
const child = spawn('node', args, {
|
||||
cwd: workdir,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdout.on('data', (chunk) => {
|
||||
process.stdout.write(chunk);
|
||||
output.write(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', resolve);
|
||||
});
|
||||
await new Promise((resolve) => output.end(resolve));
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`pi /is failed with exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
- name: Persist refreshed auth.json
|
||||
if: always() && steps.write_auth.outcome == 'success'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }}
|
||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
if (!process.env.GH_TOKEN) {
|
||||
throw new Error('PI_AUTH_UPDATE_TOKEN is not configured for the pi-analyze environment');
|
||||
}
|
||||
|
||||
const authPath = path.join(process.env.PI_CODING_AGENT_DIR, 'auth.json');
|
||||
if (!fs.existsSync(authPath)) {
|
||||
core.warning('auth.json was not created; skipping auth persistence');
|
||||
return;
|
||||
}
|
||||
|
||||
const authJson = fs.readFileSync(authPath, 'utf8');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(authJson);
|
||||
} catch (error) {
|
||||
throw new Error(`Refusing to persist malformed auth.json: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const codexAuth = parsed['openai-codex'];
|
||||
if (codexAuth?.type !== 'oauth' || typeof codexAuth.refresh !== 'string' || codexAuth.refresh.length === 0) {
|
||||
throw new Error('Refusing to persist auth.json without openai-codex OAuth refresh credentials');
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
'gh',
|
||||
['secret', 'set', 'PI_AUTH_JSON', '--env', 'pi-analyze', '--repo', process.env.GITHUB_REPOSITORY],
|
||||
{ env: process.env, stdio: ['pipe', 'inherit', 'inherit'] },
|
||||
);
|
||||
child.stdin.end(authJson);
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`gh secret set failed with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
- name: Export session files
|
||||
id: export_session_files
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||
WORKDIR: ${{ steps.workdir.outputs.name }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
function findFirstJsonl(dir) {
|
||||
if (!fs.existsSync(dir)) return undefined;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFirstJsonl(entryPath);
|
||||
if (nested) return nested;
|
||||
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||
return entryPath;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
||||
const sessionFile = findFirstJsonl(path.join(outDir, 'session'));
|
||||
if (!sessionFile) {
|
||||
throw new Error('No session jsonl file found');
|
||||
}
|
||||
|
||||
const sessionJsonl = path.join(outDir, 'session.jsonl');
|
||||
const sessionHtml = path.join(outDir, 'session.html');
|
||||
fs.copyFileSync(sessionFile, sessionJsonl);
|
||||
|
||||
const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR);
|
||||
const exitCode = await new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
'node',
|
||||
['packages/coding-agent/src/cli.ts', '--no-extensions', '--export', sessionJsonl, sessionHtml],
|
||||
{ cwd: workdir, env: process.env, stdio: 'inherit' },
|
||||
);
|
||||
child.on('error', reject);
|
||||
child.on('close', resolve);
|
||||
});
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`session export failed with exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
- name: Upload session gist
|
||||
id: gist
|
||||
if: always() && steps.export_session_files.outcome == 'success'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.PI_GIST_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
if (!process.env.PI_GIST_TOKEN) {
|
||||
throw new Error('PI_GIST_TOKEN is not configured');
|
||||
}
|
||||
|
||||
const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out');
|
||||
const files = {};
|
||||
for (const filename of ['session.html', 'session.jsonl']) {
|
||||
files[filename] = { content: fs.readFileSync(path.join(outDir, filename), 'utf8') };
|
||||
}
|
||||
|
||||
const response = await github.rest.gists.create({
|
||||
public: false,
|
||||
files,
|
||||
});
|
||||
const gistUrl = response.data.html_url;
|
||||
const gistId = response.data.id;
|
||||
core.setOutput('url', gistUrl);
|
||||
core.setOutput('id', gistId);
|
||||
core.setOutput('share_url', `https://pi.dev/session/#${gistId}`);
|
||||
|
||||
- name: Comment with session import instructions
|
||||
if: always() && steps.gist.outcome == 'success'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
GIST_URL: ${{ steps.gist.outputs.url }}
|
||||
GIST_ID: ${{ steps.gist.outputs.id }}
|
||||
SHARE_URL: ${{ steps.gist.outputs.share_url }}
|
||||
SESSION_JSONL: ${{ runner.temp }}/pi-out/session.jsonl
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
function extractLastAgentMessage(sessionPath) {
|
||||
const lines = fs.readFileSync(sessionPath, 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
let lastText = '';
|
||||
|
||||
for (const line of lines) {
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type !== 'message' || entry.message?.role !== 'assistant') continue;
|
||||
|
||||
const content = entry.message.content;
|
||||
const parts = [];
|
||||
if (typeof content === 'string') {
|
||||
parts.push(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block?.type === 'text' && typeof block.text === 'string') {
|
||||
parts.push(block.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = parts.join('\n\n').trim();
|
||||
if (text) lastText = text;
|
||||
}
|
||||
|
||||
if (!lastText) return '_No assistant output found._';
|
||||
const maxLength = 55000;
|
||||
if (lastText.length <= maxLength) return lastText;
|
||||
return `${lastText.slice(0, maxLength)}\n\n_[truncated]_`;
|
||||
}
|
||||
|
||||
const gistUrl = process.env.GIST_URL;
|
||||
const gistId = process.env.GIST_ID;
|
||||
const shareUrl = process.env.SHARE_URL;
|
||||
const lastAgentMessage = extractLastAgentMessage(process.env.SESSION_JSONL);
|
||||
const body = [
|
||||
'Pi issue analysis finished.',
|
||||
'',
|
||||
`Share URL: ${shareUrl}`,
|
||||
`Gist: ${gistUrl}`,
|
||||
'',
|
||||
'Continue locally from a checkout with:',
|
||||
'',
|
||||
'```sh',
|
||||
`pi "/ir ${gistId}"`,
|
||||
'```',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Agent analysis summary</summary>',
|
||||
'',
|
||||
lastAgentMessage,
|
||||
'',
|
||||
'</details>',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
|
||||
- name: Remove trigger label
|
||||
if: always()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'pi-analyze',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
name: Issue Gate
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-contributor:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Check issue author
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
||||
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const isBotAuthor = issueAuthor.endsWith('[bot]');
|
||||
|
||||
if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) {
|
||||
console.log(`Skipping trusted bot: ${issueAuthor}`);
|
||||
return;
|
||||
}
|
||||
|
||||
async function getPermission(username) {
|
||||
try {
|
||||
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
return permissionLevel.permission;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTextFile(path) {
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path,
|
||||
ref: defaultBranch,
|
||||
});
|
||||
|
||||
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
|
||||
throw new Error(`Expected file content for ${path}`);
|
||||
}
|
||||
|
||||
return Buffer.from(fileContent.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function parseApprovedUsers(content) {
|
||||
const users = new Map();
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length !== 2) {
|
||||
console.log(`Skipping malformed line: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [username, capability] = parts;
|
||||
const normalizedCapability = capability.toLowerCase();
|
||||
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
||||
console.log(`Skipping line with invalid capability: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
users.set(username.toLowerCase(), normalizedCapability);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
const permission = await getPermission(issueAuthor);
|
||||
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
|
||||
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
|
||||
return;
|
||||
}
|
||||
|
||||
const approvedContent = await getTextFile(APPROVED_FILE);
|
||||
const approvedUsers = parseApprovedUsers(approvedContent);
|
||||
const capability = approvedUsers.get(issueAuthor.toLowerCase());
|
||||
|
||||
if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) {
|
||||
console.log(`${issueAuthor} is approved for ${capability}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = [
|
||||
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
|
||||
'',
|
||||
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
||||
'',
|
||||
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message,
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['untriaged'],
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
name: Issue Triage Labels
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [reopened, labeled]
|
||||
|
||||
jobs:
|
||||
update-labels:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Update triage labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const UNTRIAGED_LABEL = 'untriaged';
|
||||
const NO_ACTION_LABEL = 'no-action';
|
||||
const LAST_READ_LABEL = 'last-read';
|
||||
const TO_DISCUSS_LABEL = 'to-discuss';
|
||||
const INPROGRESS_LABEL = 'inprogress';
|
||||
|
||||
function issueHasLabel(issue, labelName) {
|
||||
return (issue.labels ?? []).some((label) => label.name === labelName);
|
||||
}
|
||||
|
||||
async function removeLabelIfPresent(issueNumber, issue, labelName) {
|
||||
if (!issueHasLabel(issue, labelName)) {
|
||||
console.log(`Issue #${issueNumber} does not have ${labelName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
name: labelName,
|
||||
});
|
||||
console.log(`Removed ${labelName} from #${issueNumber}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.payload.action === 'reopened') {
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.payload.action === 'labeled' && context.payload.label?.name === NO_ACTION_LABEL) {
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
|
||||
console.log('Not a last-read label event');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIssueNumber = context.issue.number;
|
||||
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'all',
|
||||
labels: LAST_READ_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const previousIssueNumbers = lastReadIssues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.map((issue) => issue.number)
|
||||
.filter((issueNumber) => issueNumber !== currentIssueNumber);
|
||||
|
||||
if (previousIssueNumbers.length === 0) {
|
||||
console.log('No previous last-read issue found');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousIssueNumber = Math.max(...previousIssueNumbers);
|
||||
if (currentIssueNumber <= previousIssueNumber) {
|
||||
console.log(
|
||||
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'all',
|
||||
labels: UNTRIAGED_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const issuesToMark = untriagedIssues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
|
||||
.sort((a, b) => a.number - b.number);
|
||||
|
||||
if (issuesToMark.length === 0) {
|
||||
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const issue of issuesToMark) {
|
||||
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
|
||||
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
|
||||
} else {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [NO_ACTION_LABEL],
|
||||
});
|
||||
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
|
||||
}
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
console.log(`Closed #${issue.number} as not planned`);
|
||||
|
||||
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: UNTRIAGED_LABEL,
|
||||
});
|
||||
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
name: npm audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '37 7 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies without lifecycle scripts
|
||||
run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Audit production vulnerabilities
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
|
||||
- name: Verify registry signatures
|
||||
run: npm audit signatures --omit=dev
|
||||
@@ -1,128 +0,0 @@
|
||||
name: PR Gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-contributor:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if contributor is approved
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
||||
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const isBotAuthor = prAuthor.endsWith('[bot]');
|
||||
|
||||
if (TRUSTED_BOT_AUTHORS.has(prAuthor)) {
|
||||
console.log(`Skipping trusted bot: ${prAuthor}`);
|
||||
return;
|
||||
}
|
||||
|
||||
async function getPermission(username) {
|
||||
try {
|
||||
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
return permissionLevel.permission;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTextFile(path) {
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path,
|
||||
ref: defaultBranch,
|
||||
});
|
||||
|
||||
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
|
||||
throw new Error(`Expected file content for ${path}`);
|
||||
}
|
||||
|
||||
return Buffer.from(fileContent.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function parseApprovedUsers(content) {
|
||||
const users = new Map();
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length !== 2) {
|
||||
console.log(`Skipping malformed line: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [username, capability] = parts;
|
||||
const normalizedCapability = capability.toLowerCase();
|
||||
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
|
||||
console.log(`Skipping line with invalid capability: ${rawLine}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
users.set(username.toLowerCase(), normalizedCapability);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async function closePullRequest(message) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: message,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
|
||||
const permission = await getPermission(prAuthor);
|
||||
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
|
||||
console.log(`${prAuthor} is a collaborator with ${permission} access`);
|
||||
return;
|
||||
}
|
||||
|
||||
const approvedContent = await getTextFile(APPROVED_FILE);
|
||||
const approvedUsers = parseApprovedUsers(approvedContent);
|
||||
const capability = approvedUsers.get(prAuthor.toLowerCase());
|
||||
|
||||
if (!isBotAuthor && capability === 'pr') {
|
||||
console.log(`${prAuthor} is approved for PRs`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${prAuthor} is not approved, closing PR`);
|
||||
|
||||
const message = [
|
||||
'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first.',
|
||||
'',
|
||||
`Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
||||
'',
|
||||
'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
||||
].join('\n');
|
||||
|
||||
await closePullRequest(message);
|
||||
@@ -1,146 +0,0 @@
|
||||
name: Publish Model Catalog
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/publish-model-catalog.yml'
|
||||
- '.gitignore'
|
||||
- 'package.json'
|
||||
- 'packages/ai/**'
|
||||
- 'scripts/publish-model-catalog.mjs'
|
||||
# GitHub schedules use UTC. Run hourly candidates across the CET/CEST
|
||||
# boundaries; the publish job only uploads at 10:17, 12:17, and 14:17
|
||||
# Europe/Vienna time.
|
||||
schedule:
|
||||
- cron: '17 8-13 * * 1-5'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: 'Commit, branch, or tag to generate from'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
publish:
|
||||
description: 'Upload the generated catalog to production R2'
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Generate model catalog JSON
|
||||
run: npm run generate:model-catalog
|
||||
|
||||
- name: Validate model catalog JSON
|
||||
run: npm run check:model-catalog
|
||||
|
||||
- name: Upload model catalog JSON
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: model-catalog-json
|
||||
path: .artifacts/model-catalog
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.publish) }}
|
||||
needs: generate
|
||||
runs-on: ubuntu-latest
|
||||
environment: pi-model-upload
|
||||
concurrency:
|
||||
group: publish-model-catalog-r2
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_EC2_METADATA_DISABLED: 'true'
|
||||
R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Download model catalog JSON
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: model-catalog-json
|
||||
path: .artifacts/model-catalog
|
||||
|
||||
- name: Verify AWS CLI
|
||||
run: aws --version
|
||||
|
||||
- name: Check publication window
|
||||
id: publication-window
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
MANUAL_PUBLISH: ${{ inputs.publish || false }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
local_day="$(TZ=Europe/Vienna date +%u)"
|
||||
local_hour_text="$(TZ=Europe/Vienna date +%H)"
|
||||
local_hour="$((10#$local_hour_text))"
|
||||
local_time="$(TZ=Europe/Vienna date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
allowed=false
|
||||
reason="outside the Monday-Friday 10:00-15:00 Europe/Vienna publication window"
|
||||
|
||||
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PUBLISH" == "true" ]]; then
|
||||
allowed=true
|
||||
reason="manual publication"
|
||||
elif (( local_day <= 5 && local_hour >= 10 && local_hour < 15 )); then
|
||||
if [[ "$EVENT_NAME" != "schedule" ]] || (( (local_hour - 10) % 2 == 0 )); then
|
||||
allowed=true
|
||||
reason="business-hours publication"
|
||||
else
|
||||
reason="not a scheduled 10:17, 12:17, or 14:17 Europe/Vienna publication"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "allowed=$allowed" >> "$GITHUB_OUTPUT"
|
||||
echo "R2 publication allowed: $allowed ($reason; local time: $local_time)"
|
||||
|
||||
- name: Publish model catalog to R2
|
||||
if: steps.publication-window.outputs.allowed == 'true'
|
||||
run: |
|
||||
node scripts/publish-model-catalog.mjs \
|
||||
--input .artifacts/model-catalog \
|
||||
--bucket pi-artifacts \
|
||||
--endpoint "$R2_ENDPOINT" \
|
||||
--source-commit "$(git rev-parse HEAD)"
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Remove In Progress Label On Close
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
remove-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove inprogress label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const labelName = 'inprogress';
|
||||
const labels = context.payload.issue.labels ?? [];
|
||||
const hasLabel = labels.some((label) => label.name === labelName);
|
||||
|
||||
if (!hasLabel) {
|
||||
console.log(`Issue does not have ${labelName} label`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: labelName,
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Get list of staged files before running check
|
||||
STAGED_FILES=$(git diff --cached --name-only)
|
||||
|
||||
node scripts/check-lockfile-commit.mjs
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the check script (formatting, linting, and type checking)
|
||||
echo "Running formatting, linting, and type checking..."
|
||||
npm run check
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Checks failed. Please fix the errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUN_BROWSER_SMOKE=0
|
||||
for file in $STAGED_FILES; do
|
||||
case "$file" in
|
||||
packages/ai/*|packages/web-ui/*|package.json|package-lock.json)
|
||||
RUN_BROWSER_SMOKE=1
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $RUN_BROWSER_SMOKE -eq 1 ]; then
|
||||
echo "Running browser smoke check..."
|
||||
npm run check:browser-smoke
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Browser smoke check failed."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restage files that were previously staged and may have been modified by formatting
|
||||
for file in $STAGED_FILES; do
|
||||
if [ -f "$file" ]; then
|
||||
git add "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ All pre-commit checks passed!"
|
||||
@@ -1,351 +0,0 @@
|
||||
/**
|
||||
* Import a pi session shared as a gist by the issue-analysis CI workflow
|
||||
* (.github/workflows/issue-analysis.yml) and switch to it.
|
||||
*
|
||||
* The CI job runs in a high-entropy checkout directory; this command rewrites
|
||||
* the recorded cwd to the local checkout, installs the session file into the
|
||||
* current session directory, and switches to it.
|
||||
*
|
||||
* Usage:
|
||||
* /ir b4d100022aefb12f25dd2d8485e0a82a
|
||||
* /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a
|
||||
* /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a
|
||||
* /ir https://github.com/earendil-works/pi/issues/123
|
||||
*
|
||||
* pi "/ir <gist-id>"
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const GIST_ID_RE = /^[0-9a-fA-F]{20,}$/;
|
||||
const GIST_URL_RE = /^https:\/\/gist\.github\.com\/(?:[^/]+\/)?([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
|
||||
const SHARE_URL_RE = /^https:\/\/pi\.dev\/session\/#([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
|
||||
const ISSUE_URL_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/#?].*)?$/;
|
||||
const GIST_URL_IN_TEXT_RE = /https:\/\/gist\.github\.com\/(?:[^/\s]+\/)?([0-9a-fA-F]{20,})\b/g;
|
||||
const SESSION_DATA_RE = /<script id="session-data" type="application\/json">([^<]+)<\/script>/;
|
||||
|
||||
interface SessionHeader {
|
||||
type: "session";
|
||||
id: string;
|
||||
cwd: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ExportedSessionData {
|
||||
header: SessionHeader | null;
|
||||
entries: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface GistFile {
|
||||
filename?: string;
|
||||
raw_url?: string;
|
||||
content?: string;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
interface GistResponse {
|
||||
files?: Record<string, GistFile>;
|
||||
}
|
||||
|
||||
interface IssueComment {
|
||||
body?: string | null;
|
||||
user?: { login?: string } | null;
|
||||
}
|
||||
|
||||
function parseRef(
|
||||
ref: string,
|
||||
cwd: string,
|
||||
): { type: "gist"; id: string } | { type: "file"; path: string } | { type: "issue"; owner: string; repo: string; issue: string } {
|
||||
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
|
||||
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
|
||||
}
|
||||
|
||||
const shareMatch = ref.match(SHARE_URL_RE);
|
||||
if (shareMatch) return { type: "gist", id: shareMatch[1] };
|
||||
|
||||
const gistMatch = ref.match(GIST_URL_RE);
|
||||
if (gistMatch) return { type: "gist", id: gistMatch[1] };
|
||||
|
||||
const issueMatch = ref.match(ISSUE_URL_RE);
|
||||
if (issueMatch) return { type: "issue", owner: issueMatch[1], repo: issueMatch[2], issue: issueMatch[3] };
|
||||
|
||||
if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref };
|
||||
|
||||
throw new Error(`expected a gist ID, gist URL, pi.dev share URL, issue URL, .html file, or .jsonl file: ${ref}`);
|
||||
}
|
||||
|
||||
function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } {
|
||||
const newlineIndex = raw.indexOf("\n");
|
||||
const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(firstLine);
|
||||
} catch {
|
||||
throw new Error("first line of session file is not valid JSON");
|
||||
}
|
||||
const header = parsed as Partial<SessionHeader>;
|
||||
if (header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string" || header.cwd === "") {
|
||||
throw new Error("session file has no valid session header with a cwd");
|
||||
}
|
||||
return { header: header as SessionHeader, jsonl: raw };
|
||||
}
|
||||
|
||||
function decodeExportedHtml(html: string): { header: SessionHeader; jsonl: string } {
|
||||
const match = html.match(SESSION_DATA_RE);
|
||||
if (!match) throw new Error("HTML does not contain embedded pi session data");
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("embedded pi session data is not valid JSON");
|
||||
}
|
||||
|
||||
const sessionData = data as Partial<ExportedSessionData>;
|
||||
const header = sessionData.header;
|
||||
if (!header || header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
|
||||
throw new Error("embedded pi session data has no valid session header");
|
||||
}
|
||||
if (!Array.isArray(sessionData.entries)) {
|
||||
throw new Error("embedded pi session data has no entries array");
|
||||
}
|
||||
|
||||
const lines = [header, ...sessionData.entries].map((entry) => JSON.stringify(entry));
|
||||
return { header, jsonl: `${lines.join("\n")}\n` };
|
||||
}
|
||||
|
||||
type SessionPlatform = "windows" | "unix" | "unknown";
|
||||
|
||||
function escapeJsonString(value: string): string {
|
||||
return JSON.stringify(value).slice(1, -1);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function trimTrailingPathSeparators(value: string): string {
|
||||
return value.replace(/[\\/]+$/, "");
|
||||
}
|
||||
|
||||
function getPathTailName(value: string): string {
|
||||
const trimmed = trimTrailingPathSeparators(value);
|
||||
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) ?? "";
|
||||
}
|
||||
|
||||
function getWindowsDrivePathParts(value: string): { drive: string; rest: string } | undefined {
|
||||
const trimmed = trimTrailingPathSeparators(value);
|
||||
const driveMatch = trimmed.match(/^([A-Za-z]):[\\/](.*)$/);
|
||||
if (driveMatch) {
|
||||
return { drive: driveMatch[1].toUpperCase(), rest: driveMatch[2].replace(/[\\/]+/g, "/") };
|
||||
}
|
||||
|
||||
const msysMatch = trimmed.match(/^\/([A-Za-z])\/(.*)$/);
|
||||
if (msysMatch) {
|
||||
return { drive: msysMatch[1].toUpperCase(), rest: msysMatch[2].replace(/[\\/]+/g, "/") };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCwdRewriteVariants(sourceCwd: string): string[] {
|
||||
const trimmed = trimTrailingPathSeparators(sourceCwd);
|
||||
const variants = new Set<string>();
|
||||
if (trimmed) variants.add(trimmed);
|
||||
|
||||
const driveParts = getWindowsDrivePathParts(trimmed);
|
||||
if (driveParts) {
|
||||
const rest = driveParts.rest.replace(/^\/+|\/+$/g, "");
|
||||
const backslashRest = rest.replace(/\//g, "\\");
|
||||
variants.add(`${driveParts.drive}:\\${backslashRest}`);
|
||||
variants.add(`${driveParts.drive}:/${rest}`);
|
||||
variants.add(`/${driveParts.drive.toLowerCase()}/${rest}`);
|
||||
variants.add(`/${driveParts.drive}/${rest}`);
|
||||
}
|
||||
|
||||
return Array.from(variants).filter(Boolean).sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
function getCiWorkdirName(sourceCwd: string): string | undefined {
|
||||
const name = getPathTailName(sourceCwd);
|
||||
return /^pi-ci-[0-9a-f]{32}$/i.test(name) ? name : undefined;
|
||||
}
|
||||
|
||||
function detectSessionPlatform(cwd: string): SessionPlatform {
|
||||
if (/^[A-Za-z]:[\\/]/.test(cwd) || /^\/[A-Za-z]\//.test(cwd)) return "windows";
|
||||
if (cwd.startsWith("/")) return "unix";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function getLocalPlatform(): Exclude<SessionPlatform, "unknown"> {
|
||||
return process.platform === "win32" ? "windows" : "unix";
|
||||
}
|
||||
|
||||
function getPlatformContinuationNotice(sourceCwd: string): string | undefined {
|
||||
const sourcePlatform = detectSessionPlatform(sourceCwd);
|
||||
const localPlatform = getLocalPlatform();
|
||||
if (sourcePlatform === "unknown" || sourcePlatform === localPlatform) return undefined;
|
||||
if (localPlatform === "unix") {
|
||||
return "This session was continued on a non-Windows machine; paths are now Unix style.";
|
||||
}
|
||||
return "This session was continued on a Windows machine; paths are now Windows style.";
|
||||
}
|
||||
|
||||
/** Rewrite occurrences of the recorded CI cwd (JSON-escaped) to the target cwd. */
|
||||
function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): string {
|
||||
const target = escapeJsonString(targetCwd);
|
||||
let rewritten = raw;
|
||||
|
||||
for (const sourceVariant of getCwdRewriteVariants(sourceCwd)) {
|
||||
if (sourceVariant === targetCwd) continue;
|
||||
rewritten = rewritten.split(escapeJsonString(sourceVariant)).join(target);
|
||||
}
|
||||
|
||||
const ciWorkdirName = getCiWorkdirName(sourceCwd);
|
||||
if (ciWorkdirName) {
|
||||
const escapedName = escapeRegExp(ciWorkdirName);
|
||||
const windowsPathPatterns = [
|
||||
new RegExp(`[A-Za-z]:(?:[^"\\r\\n])*?${escapedName}`, "g"),
|
||||
new RegExp(`/[A-Za-z]/(?:[^"\\r\\n])*?${escapedName}`, "g"),
|
||||
];
|
||||
for (const pattern of windowsPathPatterns) {
|
||||
rewritten = rewritten.replace(pattern, target);
|
||||
}
|
||||
}
|
||||
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url, { headers: { Accept: "application/vnd.github+json" } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch ${url}: HTTP ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
async function readGistFile(file: GistFile): Promise<string> {
|
||||
if (file.content && !file.truncated) return file.content;
|
||||
if (!file.raw_url) throw new Error(`gist file ${file.filename ?? "<unknown>"} has no raw URL`);
|
||||
return await fetchText(file.raw_url);
|
||||
}
|
||||
|
||||
async function findIssueGistId(owner: string, repo: string, issue: string): Promise<string> {
|
||||
const gistIds: string[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${encodeURIComponent(issue)}/comments?per_page=100&page=${page}`,
|
||||
{ headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } },
|
||||
);
|
||||
if (!response.ok) throw new Error(`failed to fetch issue comments: HTTP ${response.status}`);
|
||||
|
||||
const comments = (await response.json()) as IssueComment[];
|
||||
for (const comment of comments) {
|
||||
if (comment.user?.login !== "github-actions[bot]") continue;
|
||||
for (const match of (comment.body ?? "").matchAll(GIST_URL_IN_TEXT_RE)) {
|
||||
gistIds.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (comments.length < 100) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
const gistId = gistIds.at(-1);
|
||||
if (!gistId) throw new Error(`no github-actions gist link found in comments on ${owner}/${repo}#${issue}`);
|
||||
return gistId;
|
||||
}
|
||||
|
||||
async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader; jsonl: string }> {
|
||||
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`failed to fetch gist ${gistId}: HTTP ${response.status}`);
|
||||
|
||||
const gist = (await response.json()) as GistResponse;
|
||||
const files = Object.values(gist.files ?? {});
|
||||
const jsonlFile = files.find((file) => file.filename?.endsWith(".jsonl"));
|
||||
if (jsonlFile) return parseSessionJsonl(await readGistFile(jsonlFile));
|
||||
|
||||
const htmlFile = files.find((file) => file.filename?.endsWith(".html"));
|
||||
if (htmlFile) return decodeExportedHtml(await readGistFile(htmlFile));
|
||||
|
||||
throw new Error(`gist ${gistId} has no .jsonl or .html session file`);
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("ir", {
|
||||
description: "Import a CI issue-analysis session from a gist ID, share URL, or issue URL and switch to it",
|
||||
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
||||
const ref = args.trim();
|
||||
if (!ref) {
|
||||
ctx.ui.notify("Usage: /ir <gist-id | gist-url | pi.dev/session URL | issue URL>", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const targetCwd = ctx.sessionManager.getCwd();
|
||||
const sessionDir = ctx.sessionManager.getSessionDir();
|
||||
const parsedRef = parseRef(ref, targetCwd);
|
||||
|
||||
ctx.ui.notify(`Importing repro session from ${ref}...`, "info");
|
||||
|
||||
let sourceName: string;
|
||||
let decoded: { header: SessionHeader; jsonl: string };
|
||||
if (parsedRef.type === "gist") {
|
||||
decoded = await fetchGistSession(parsedRef.id);
|
||||
sourceName = `${parsedRef.id}.jsonl`;
|
||||
} else if (parsedRef.type === "issue") {
|
||||
const gistId = await findIssueGistId(parsedRef.owner, parsedRef.repo, parsedRef.issue);
|
||||
decoded = await fetchGistSession(gistId);
|
||||
sourceName = `${gistId}.jsonl`;
|
||||
} else {
|
||||
if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`);
|
||||
const raw = readFileSync(parsedRef.path, "utf8");
|
||||
decoded = parsedRef.path.endsWith(".html") ? decodeExportedHtml(raw) : parseSessionJsonl(raw);
|
||||
sourceName = basename(parsedRef.path).replace(/\.html$/, ".jsonl");
|
||||
}
|
||||
|
||||
const platformNotice = getPlatformContinuationNotice(decoded.header.cwd);
|
||||
const rewritten = rewriteSessionCwd(decoded.jsonl, decoded.header.cwd, targetCwd);
|
||||
const destination = join(sessionDir, sourceName);
|
||||
if (existsSync(destination)) {
|
||||
const overwrite = await ctx.ui.confirm(
|
||||
"Session already imported",
|
||||
`Overwrite ${destination}? Local changes to that session will be lost.`,
|
||||
);
|
||||
if (!overwrite) {
|
||||
ctx.ui.notify("Import cancelled", "warning");
|
||||
return;
|
||||
}
|
||||
}
|
||||
writeFileSync(destination, rewritten);
|
||||
|
||||
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
|
||||
await ctx.switchSession(destination, {
|
||||
withSession: async (nextCtx) => {
|
||||
if (!platformNotice) return;
|
||||
await nextCtx.sendMessage(
|
||||
{
|
||||
customType: "import-repro",
|
||||
content: platformNotice,
|
||||
display: true,
|
||||
details: { sourceCwd: decoded.header.cwd, targetCwd },
|
||||
},
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`ir: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Container, Text } from "@earendil-works/pi-tui";
|
||||
|
||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||
const ADVISORY_PROMPT_PATTERN = /^\s*Update a GitHub security advisory for publication:\s*(\S+)/im;
|
||||
|
||||
type PromptMatch = {
|
||||
kind: "pr" | "issue" | "advisory";
|
||||
target: string;
|
||||
};
|
||||
|
||||
type GhMetadata = {
|
||||
title?: string;
|
||||
detail?: string;
|
||||
displayUrl?: string;
|
||||
author?: {
|
||||
login?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type GitHubAdvisoryMetadata = {
|
||||
ghsa_id?: string;
|
||||
summary?: string;
|
||||
severity?: string;
|
||||
state?: string;
|
||||
html_url?: string;
|
||||
cve_id?: string | null;
|
||||
};
|
||||
|
||||
type AdvisoryRef = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
ghsaId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||
if (prMatch?.[1]) {
|
||||
return { kind: "pr", target: prMatch[1].trim() };
|
||||
}
|
||||
|
||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||
if (issueMatch?.[1]) {
|
||||
return { kind: "issue", target: issueMatch[1].trim() };
|
||||
}
|
||||
|
||||
const advisoryMatch = prompt.match(ADVISORY_PROMPT_PATTERN);
|
||||
if (advisoryMatch?.[1]) {
|
||||
return { kind: "advisory", target: advisoryMatch[1].trim() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getPromptLabel(kind: PromptMatch["kind"]): string {
|
||||
if (kind === "pr") return "PR";
|
||||
if (kind === "issue") return "Issue";
|
||||
return "Advisory";
|
||||
}
|
||||
|
||||
function parseAdvisoryUrl(value: string): AdvisoryRef | undefined {
|
||||
const match = value.match(
|
||||
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/security\/advisories\/(GHSA-[A-Za-z0-9-]+)(?:[/?#].*)?$/i,
|
||||
);
|
||||
if (!match?.[1] || !match[2] || !match[3]) return undefined;
|
||||
return {
|
||||
owner: match[1],
|
||||
repo: match[2],
|
||||
ghsaId: match[3],
|
||||
url: `https://github.com/${match[1]}/${match[2]}/security/advisories/${match[3]}`,
|
||||
};
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveDraftPath(cwd: string, target: string): string {
|
||||
if (target === "~") return homedir();
|
||||
if (target.startsWith("~/")) return resolve(homedir(), target.slice(2));
|
||||
return resolve(cwd, target);
|
||||
}
|
||||
|
||||
async function readAdvisoryRefFromDraft(cwd: string, target: string): Promise<AdvisoryRef | undefined> {
|
||||
try {
|
||||
const content = await readFile(resolveDraftPath(cwd, target), "utf8");
|
||||
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const body = frontmatter?.[1] ?? content;
|
||||
const urlMatch = body.match(/^advisory_url:\s*(.+)$/m);
|
||||
if (!urlMatch?.[1]) return undefined;
|
||||
return parseAdvisoryUrl(unquoteYamlValue(urlMatch[1]));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAdvisoryDetail(advisory: GitHubAdvisoryMetadata): string | undefined {
|
||||
const parts = [advisory.ghsa_id, advisory.cve_id ?? undefined, advisory.severity, advisory.state]
|
||||
.map((part) => part?.trim())
|
||||
.filter((part): part is string => part !== undefined && part.length > 0);
|
||||
return parts.length > 0 ? parts.join(" · ") : undefined;
|
||||
}
|
||||
|
||||
async function fetchAdvisoryMetadata(pi: ExtensionAPI, cwd: string, target: string): Promise<GhMetadata | undefined> {
|
||||
const advisoryRef = parseAdvisoryUrl(target) ?? (await readAdvisoryRefFromDraft(cwd, target));
|
||||
if (!advisoryRef) return undefined;
|
||||
|
||||
try {
|
||||
const result = await pi.exec("gh", [
|
||||
"api",
|
||||
`repos/${advisoryRef.owner}/${advisoryRef.repo}/security-advisories/${advisoryRef.ghsaId}`,
|
||||
]);
|
||||
if (result.code !== 0 || !result.stdout) return { displayUrl: advisoryRef.url };
|
||||
const advisory = JSON.parse(result.stdout) as GitHubAdvisoryMetadata;
|
||||
return {
|
||||
title: advisory.summary,
|
||||
detail: formatAdvisoryDetail(advisory),
|
||||
displayUrl: advisory.html_url ?? advisoryRef.url,
|
||||
};
|
||||
} catch {
|
||||
return { displayUrl: advisoryRef.url };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGhMetadata(
|
||||
pi: ExtensionAPI,
|
||||
kind: PromptMatch["kind"],
|
||||
target: string,
|
||||
cwd: string,
|
||||
): Promise<GhMetadata | undefined> {
|
||||
if (kind === "advisory") {
|
||||
return fetchAdvisoryMetadata(pi, cwd, target);
|
||||
}
|
||||
|
||||
const args =
|
||||
kind === "pr"
|
||||
? ["pr", "view", target, "--json", "title,author"]
|
||||
: ["issue", "view", target, "--json", "title,author"];
|
||||
|
||||
try {
|
||||
const result = await pi.exec("gh", args);
|
||||
if (result.code !== 0 || !result.stdout) return undefined;
|
||||
return JSON.parse(result.stdout) as GhMetadata;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||
if (!author) return undefined;
|
||||
const name = author.name?.trim();
|
||||
const login = author.login?.trim();
|
||||
if (name && login) return `${name} (@${login})`;
|
||||
if (login) return `@${login}`;
|
||||
if (name) return name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
const setWidget = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
|
||||
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||
const displayTarget = metadata?.displayUrl ?? match.target;
|
||||
const titleText = metadata?.title
|
||||
? thm.fg("accent", metadata.title)
|
||||
: thm.fg("accent", displayTarget);
|
||||
const detailText = metadata?.detail ?? formatAuthor(metadata?.author);
|
||||
const detailLine = detailText ? thm.fg("muted", detailText) : undefined;
|
||||
const urlLine = thm.fg("dim", displayTarget);
|
||||
|
||||
const lines = [titleText];
|
||||
if (detailLine) lines.push(detailLine);
|
||||
lines.push(urlLine);
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
||||
container.addChild(new Text(lines.join("\n"), 1, 0));
|
||||
return container;
|
||||
});
|
||||
};
|
||||
|
||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
|
||||
const label = getPromptLabel(match.kind);
|
||||
const displayTarget = metadata?.displayUrl ?? match.target;
|
||||
const trimmedTitle = metadata?.title?.trim();
|
||||
const fallbackName = `${label}: ${match.target}`;
|
||||
const desiredFallbackName = `${label}: ${displayTarget}`;
|
||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${displayTarget})` : desiredFallbackName;
|
||||
const currentName = pi.getSessionName()?.trim();
|
||||
if (!currentName) {
|
||||
pi.setSessionName(desiredName);
|
||||
return;
|
||||
}
|
||||
if (currentName === match.target || currentName === fallbackName || currentName === desiredFallbackName) {
|
||||
pi.setSessionName(desiredName);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePromptContext = (ctx: ExtensionContext, match: PromptMatch) => {
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.target, ctx.cwd).then((meta) => {
|
||||
setWidget(ctx, match, meta);
|
||||
applySessionName(ctx, match, meta);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
const match = extractPromptMatch(event.prompt);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
updatePromptContext(ctx, match);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
|
||||
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
return (
|
||||
content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const lastMatch = [...entries].reverse().find((entry) => {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") return false;
|
||||
const text = getUserText(entry.message.content);
|
||||
return !!extractPromptMatch(text);
|
||||
});
|
||||
|
||||
const content =
|
||||
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
|
||||
const text = getUserText(content);
|
||||
const match = text ? extractPromptMatch(text) : undefined;
|
||||
if (!match) {
|
||||
ctx.ui.setWidget("prompt-url", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
updatePromptContext(ctx, match);
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Redraws Extension
|
||||
*
|
||||
* Exposes /tui to show TUI redraw stats.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("tui", {
|
||||
description: "Show TUI stats",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
let redraws = 0;
|
||||
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||
redraws = tui.fullRedraws;
|
||||
done(undefined);
|
||||
return new Text("", 0, 0);
|
||||
});
|
||||
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
function isAssistantMessage(message: unknown): message is AssistantMessage {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return role === "assistant";
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let agentStartMs: number | null = null;
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
agentStartMs = Date.now();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
if (agentStartMs === null) return;
|
||||
|
||||
const elapsedMs = Date.now() - agentStartMs;
|
||||
agentStartMs = null;
|
||||
if (elapsedMs <= 0) return;
|
||||
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let cacheRead = 0;
|
||||
let cacheWrite = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
for (const message of event.messages) {
|
||||
if (!isAssistantMessage(message)) continue;
|
||||
input += message.usage.input || 0;
|
||||
output += message.usage.output || 0;
|
||||
cacheRead += message.usage.cacheRead || 0;
|
||||
cacheWrite += message.usage.cacheWrite || 0;
|
||||
totalTokens += message.usage.totalTokens || 0;
|
||||
}
|
||||
|
||||
if (output <= 0) return;
|
||||
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const tokensPerSecond = output / elapsedSeconds;
|
||||
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
|
||||
ctx.ui.notify(message, "info");
|
||||
});
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
Audit changelog entries for all commits since the last release.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Find the last release tag:**
|
||||
```bash
|
||||
git tag --sort=-version:refname | head -1
|
||||
```
|
||||
|
||||
2. **List all commits since that tag:**
|
||||
```bash
|
||||
git log <tag>..HEAD --oneline
|
||||
```
|
||||
|
||||
3. **Read each package's [Unreleased] section:**
|
||||
- packages/ai/CHANGELOG.md
|
||||
- packages/tui/CHANGELOG.md
|
||||
- packages/coding-agent/CHANGELOG.md
|
||||
|
||||
4. **For each commit, check:**
|
||||
- Skip: changelog updates, doc-only changes, release housekeeping
|
||||
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
|
||||
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
|
||||
- Verify a changelog entry exists in the affected package(s)
|
||||
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
|
||||
|
||||
5. **Cross-package duplication rule:**
|
||||
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
|
||||
|
||||
6. **Add New Features section after changelog fixes:**
|
||||
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
|
||||
- Propose the top new features to the user for confirmation before writing them.
|
||||
- Link to relevant docs and sections whenever possible.
|
||||
|
||||
7. **Report:**
|
||||
- List commits with missing entries
|
||||
- List entries that need cross-package duplication
|
||||
- Add any missing entries directly
|
||||
|
||||
## Changelog Format Reference
|
||||
|
||||
Sections (in order):
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
|
||||
Attribution:
|
||||
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
|
||||
- External: `Added bar ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@user](https://github.com/user))`
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
For each issue:
|
||||
|
||||
1. If running under CI (`CI=true`), do not add the `inprogress` label and do not assign the issue. Otherwise, add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
|
||||
2. Read the issue in full, including all comments and linked issues/PRs. Use fields supported by GitHub CLI, for example:
|
||||
```sh
|
||||
gh issue view <issue> --json title,body,comments,labels,assignees,state,url,author,createdAt,updatedAt,closedByPullRequestsReferences
|
||||
```
|
||||
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
|
||||
|
||||
4. **For bugs**:
|
||||
- Ignore any root cause analysis in the issue (likely wrong)
|
||||
- Read all related code files in full (no truncation)
|
||||
- Trace the code path and identify the actual root cause
|
||||
- Propose a fix
|
||||
|
||||
5. **For feature requests**:
|
||||
- Do not trust implementation proposals in the issue without verification
|
||||
- Read all related code files in full (no truncation)
|
||||
- Propose the most concise implementation approach
|
||||
- List affected files and changes needed
|
||||
|
||||
Do NOT implement unless explicitly asked. Analyze and propose only.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@
|
||||
|
||||
For each PR URL, do the following in order:
|
||||
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
||||
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
|
||||
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
|
||||
4. Analyze the PR diff without checking out or switching to the PR branch. Use `gh pr diff`, `gh pr view`, `gh api`, and local main-branch files; if PR file contents are needed, use fetched refs with `git show <ref>:<path>` or temporary files. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
|
||||
5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging.
|
||||
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
|
||||
7. Provide a structured review with these sections:
|
||||
- What it does: one short paragraph describing the change and its intent.
|
||||
- Good: solid choices or improvements.
|
||||
- Bad: concrete issues, regressions, missing tests, or risks.
|
||||
- Ugly: subtle or high impact problems.
|
||||
- Tests: what is covered, what is missing, and whether existing tests are adequate.
|
||||
- Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none.
|
||||
|
||||
Output format per PR:
|
||||
PR: <url>
|
||||
What it does:
|
||||
- ...
|
||||
Good:
|
||||
- ...
|
||||
Bad:
|
||||
- ...
|
||||
Ugly:
|
||||
- ...
|
||||
Tests:
|
||||
- ...
|
||||
Open questions for you:
|
||||
- ...
|
||||
|
||||
If no issues are found, say so under Bad and Ugly.
|
||||
@@ -1,163 +0,0 @@
|
||||
---
|
||||
description: Update a GitHub security advisory for publication
|
||||
argument-hint: "<advisory-url-or-draft-path>"
|
||||
---
|
||||
Update a GitHub security advisory for publication: $ARGUMENTS
|
||||
|
||||
Use `gh` for all GitHub operations. Do not publish the advisory, change its state, or request a CVE unless the user explicitly agrees or the draft markdown explicitly says `request_cve: true`.
|
||||
|
||||
GitHub does not expose repository security advisory comments/discussion through the documented REST OpenAPI schema or public GraphQL schema. A 404 from guessed API endpoints such as `api.github.com/repos/.../security-advisories/<GHSA>/comments`, `.../timeline`, or `.../events` is expected and is not, by itself, an auth failure. Do not use a browser session, browser cookies, or cookie extraction to fetch advisory comments. Instead, clearly tell the user that advisory comments were not included and that they can paste any relevant comments if they want them considered.
|
||||
|
||||
## Input handling
|
||||
|
||||
- If `$ARGUMENTS` is a GitHub security advisory URL, start the investigation and drafting workflow.
|
||||
- If `$ARGUMENTS` is a path to an existing markdown draft, read it and apply that draft to the advisory.
|
||||
- In a follow-up message after this prompt, if the user says "update", "apply", "looks good", or similar, treat it as approval to apply the previously written temp markdown draft. Re-read the file from disk before updating GitHub.
|
||||
- If applying a draft and there is no known draft path, ask the user for the markdown file path.
|
||||
|
||||
## Initial advisory workflow
|
||||
|
||||
1. Parse the advisory URL into `owner`, `repo`, and `GHSA` id.
|
||||
2. Fetch the advisory with:
|
||||
```sh
|
||||
gh api repos/<owner>/<repo>/security-advisories/<GHSA>
|
||||
```
|
||||
Record the advisory's original severity, CVSS vector, and CVSS score exactly as returned before proposing changes.
|
||||
3. Do not fetch advisory comments/discussion unless the user pasted them into the conversation:
|
||||
- Inspect the advisory JSON for references, credits, linked issues/PRs, and any discussion fields.
|
||||
- Do not rely on invented API endpoints such as `/comments`, `/timeline`, or `/events`; they commonly return 404 because GitHub does not expose draft advisory comments through the public API.
|
||||
- Do not use a browser session, browser cookies, or cookie extraction to fetch comments.
|
||||
- Explicitly tell the user: `Advisory comments were not included because GitHub does not expose them through the public API. Paste any relevant comments if you want them considered.`
|
||||
- If the user pasted comments, read and consider them.
|
||||
- Never pretend comments were read.
|
||||
4. Investigate independently:
|
||||
- Read the advisory text, metadata, affected package(s), version ranges, CVSS, CWE, references, and linked issues/PRs/commits.
|
||||
- Inspect relevant code history, releases, changelogs, package metadata, and tags.
|
||||
- Determine whether the vulnerability is already fixed.
|
||||
- If fixed, identify the patched version(s) and the correct affected version range.
|
||||
- Do not trust the reporter's analysis without verification.
|
||||
5. Discuss CVSS with the user before drafting the final update:
|
||||
- Propose a CVSS vector, score, and severity.
|
||||
- Explain the controversial metrics briefly.
|
||||
- Ask the user to confirm or adjust it.
|
||||
6. Ask whether a CVE should be requested from GitHub for this advisory.
|
||||
7. Draft a publication-ready advisory markdown file under `/tmp`, for example `/tmp/sa-<GHSA>.md`. Include both the original CVSS from the advisory and the proposed/confirmed updated CVSS.
|
||||
8. Tell the user:
|
||||
- the path to the temp markdown file
|
||||
- the original advisory URL
|
||||
- that they can edit the file and then say "update" or provide the path
|
||||
|
||||
## Draft markdown format
|
||||
|
||||
The draft file must contain YAML frontmatter followed by the advisory body. Include all fields needed to update GitHub and to decide whether to request a CVE.
|
||||
|
||||
```markdown
|
||||
---
|
||||
advisory_url: https://github.com/<owner>/<repo>/security/advisories/<GHSA>
|
||||
owner: <owner>
|
||||
repo: <repo>
|
||||
ghsa_id: <GHSA>
|
||||
summary: <short advisory summary>
|
||||
original_severity: <low|medium|high|critical|null>
|
||||
original_cvss_vector: <original CVSS:3.1/... or null>
|
||||
original_cvss_score: <original number or null>
|
||||
severity: <proposed/confirmed low|medium|high|critical>
|
||||
cvss_vector: <proposed/confirmed CVSS:3.1/...>
|
||||
cvss_score: <proposed/confirmed number>
|
||||
cwe_ids:
|
||||
- CWE-...
|
||||
vulnerabilities:
|
||||
- package:
|
||||
ecosystem: npm
|
||||
name: <package-name>
|
||||
vulnerable_version_range: <range>
|
||||
patched_versions: <range-or-version>
|
||||
request_cve: false
|
||||
---
|
||||
|
||||
# <Advisory title>
|
||||
|
||||
<Concise description of the vulnerability and vulnerable behavior.>
|
||||
|
||||
## Info
|
||||
|
||||
<Technical explanation of the root cause and affected component. Focus on facts needed by defenders and maintainers. Do not include PoC steps, exploit payloads, or copy-pastable exploit strings.>
|
||||
|
||||
## Impact
|
||||
|
||||
<Who can exploit it, prerequisites, confidentiality/integrity/availability impact, and realistic deployment assumptions.>
|
||||
|
||||
## Affected versions
|
||||
|
||||
- Affected: `<range>`
|
||||
- Patched: `<version or range>`
|
||||
|
||||
## The solution
|
||||
|
||||
<Describe the fix and the patched release.>
|
||||
|
||||
## Recommendations
|
||||
|
||||
<Upgrade guidance and operational mitigations.>
|
||||
|
||||
## Workarounds
|
||||
|
||||
<Workarounds if any; otherwise skip this section entirely>
|
||||
|
||||
## Timeline
|
||||
|
||||
- YYYY-MM-DD: Report received
|
||||
- YYYY-MM-DD: Fix committed
|
||||
- YYYY-MM-DD: Fixed version released
|
||||
- YYYY-MM-DD: Advisory published
|
||||
|
||||
## Credits
|
||||
|
||||
<Reporter/researcher attribution if appropriate, otherwise skip section.>
|
||||
|
||||
## References
|
||||
|
||||
- <links to releases, commits, advisories, documentation>
|
||||
```
|
||||
|
||||
Use the curl advisory style as inspiration: clear sections, direct language, affected/fixed version facts, recommendations, timeline, and credits. Do not include a PoC.
|
||||
|
||||
## Applying a draft to GitHub
|
||||
|
||||
When the user approves with "update"/similar or provides a markdown path:
|
||||
|
||||
1. Re-read the markdown file from disk. Never rely on the previously generated content in memory.
|
||||
2. Parse the YAML frontmatter and body.
|
||||
3. Build a JSON payload in a temporary file. Map fields as follows:
|
||||
- `summary` from frontmatter
|
||||
- `description` from the markdown body after frontmatter
|
||||
- `severity` from frontmatter if present
|
||||
- `cvss_vector_string` from `cvss_vector`
|
||||
- `cwe_ids` from frontmatter
|
||||
- `vulnerabilities` from frontmatter
|
||||
- Do not send `original_severity`, `original_cvss_vector`, or `original_cvss_score`; those fields are retained only for audit context.
|
||||
4. Update the advisory with:
|
||||
```sh
|
||||
gh api -X PATCH repos/<owner>/<repo>/security-advisories/<GHSA> --input /tmp/<payload>.json
|
||||
```
|
||||
5. If and only if the markdown frontmatter has `request_cve: true`, request a CVE with:
|
||||
```sh
|
||||
gh api -X POST repos/<owner>/<repo>/security-advisories/<GHSA>/cve
|
||||
```
|
||||
Treat "already requested" or "already assigned" as non-fatal and report it.
|
||||
6. Report what was updated:
|
||||
- advisory URL
|
||||
- summary
|
||||
- affected range
|
||||
- patched versions
|
||||
- original CVSS vector/score/severity
|
||||
- updated CVSS vector/score/severity
|
||||
- whether CVE was requested
|
||||
|
||||
## Safety rules
|
||||
|
||||
- Do not include PoC material in the final advisory body.
|
||||
- Do not request a CVE unless `request_cve: true` is present in the markdown file.
|
||||
- Do not publish the advisory or change its state unless the user explicitly asks.
|
||||
- Do not fetch advisory comments through browser sessions or cookies. State that comments were not included and invite the user to paste relevant comments if they want them considered.
|
||||
- If there is uncertainty in affected ranges, patched versions, CVSS, or CVE request status, ask the user before applying.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it.
|
||||
|
||||
Additional instructions: $ARGUMENTS
|
||||
|
||||
Determine context from the conversation history first.
|
||||
|
||||
Rules for context detection:
|
||||
- If the conversation already mentions a GitHub issue or PR, use that existing context.
|
||||
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
|
||||
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
|
||||
|
||||
Unless I explicitly override something in this request, do the following in order:
|
||||
|
||||
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
|
||||
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment. The comment must end with this exact standalone disclaimer line, with no variations:
|
||||
|
||||
```text
|
||||
This comment is AI-generated by `/wr`
|
||||
```
|
||||
3. Commit only files you changed in this session.
|
||||
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
|
||||
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
|
||||
6. Push the current branch.
|
||||
7. If this task is tied to exactly one GitHub issue, explicitly close that issue with reason `completed` after the push so the issue-close workflows in `.github/` run. This applies to issues only, not PRs.
|
||||
- Inspect `gh issue view <issue> --json state,stateReason,labels`.
|
||||
- If the issue is open, run `gh issue close <issue> --reason completed`.
|
||||
- If the issue is already closed with any reason other than `COMPLETED`, reopen it first, then close it with `gh issue close <issue> --reason completed` so GitHub emits a fresh close event.
|
||||
- If the issue is already closed as `COMPLETED`, leave it closed unless the `inprogress` label is still present; in that case reopen it and close it again with reason `completed`.
|
||||
|
||||
Constraints:
|
||||
- Never stage unrelated files.
|
||||
- Never use `git add .` or `git add -A`.
|
||||
- Run required checks before committing if code changed.
|
||||
- Do not open a PR unless I explicitly ask.
|
||||
- If this is not GitHub issue or PR work, do not post a GitHub comment.
|
||||
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
name: add-llm-provider
|
||||
description: Checklist for adding a new LLM provider to packages/ai. Covers core types, provider implementation, lazy registration, model generation, the full test matrix, coding-agent wiring, and docs.
|
||||
---
|
||||
|
||||
# Adding a New LLM Provider (packages/ai)
|
||||
|
||||
A new provider touches multiple files. Work through these steps in order.
|
||||
|
||||
## 1. Core Types (`packages/ai/src/types.ts`)
|
||||
|
||||
- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`).
|
||||
- Create options interface extending `StreamOptions`.
|
||||
- Add mapping to `ApiOptionsMap`.
|
||||
- Add provider name to `KnownProvider` type union.
|
||||
|
||||
## 2. Provider Implementation (`packages/ai/src/providers/`)
|
||||
|
||||
Create a provider file exporting:
|
||||
|
||||
- `stream<Provider>()` returning `AssistantMessageEventStream`.
|
||||
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping.
|
||||
- Provider-specific options interface.
|
||||
- Message/tool conversion functions.
|
||||
- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`).
|
||||
|
||||
## 3. Provider Exports and Lazy Registration
|
||||
|
||||
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`.
|
||||
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry.
|
||||
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there.
|
||||
- Add credential detection in `packages/ai/src/env-api-keys.ts`.
|
||||
|
||||
## 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
|
||||
|
||||
- Add logic to fetch/parse models from the provider source.
|
||||
- Map to the standardized `Model` interface.
|
||||
|
||||
## 5. Tests (`packages/ai/test/`)
|
||||
|
||||
- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API impl such as `openai-completions`.
|
||||
- Add the provider to the broader matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`.
|
||||
- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (e.g. GPT and Claude), add at least one pair per family.
|
||||
- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection.
|
||||
|
||||
## 6. Coding Agent (`packages/coding-agent/`)
|
||||
|
||||
- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`.
|
||||
- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth.
|
||||
- `src/cli/args.ts`: add env var documentation.
|
||||
- `README.md`: add provider setup instructions.
|
||||
- `docs/providers.md`: add setup instructions, env var, and `auth.json` key.
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- `packages/ai/README.md`: add to providers table, document options/auth, add env vars.
|
||||
- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`.
|
||||
+3
-3
@@ -31,7 +31,7 @@ Approval happens through maintainer replies on issues:
|
||||
- `lgtmi`: your future issues will not be auto-closed
|
||||
- `lgtm`: your future issues and PRs will not be auto-closed
|
||||
|
||||
`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
|
||||
The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end. `lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
|
||||
|
||||
## Quality Bar For Issues
|
||||
|
||||
@@ -45,7 +45,7 @@ If you open an issue, keep it short, concrete, and worth reading.
|
||||
- Explain why it matters.
|
||||
- If you want to implement the change yourself, say so.
|
||||
|
||||
If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`.
|
||||
If the issue is real and written well, a maintainer may reopen it or reply with `lgtmi` or `lgtm` in the command position described above.
|
||||
|
||||
## Blocking
|
||||
|
||||
@@ -55,7 +55,7 @@ If you send a large volume of issues through automation, your GitHub account wil
|
||||
|
||||
## Before Submitting a PR
|
||||
|
||||
Do not open a PR unless you have already been approved with `lgtm`.
|
||||
Do not open a PR unless you have already been approved by a maintainer using `lgtm` in the command position described above.
|
||||
|
||||
Before submitting a PR:
|
||||
|
||||
|
||||
@@ -67,10 +67,10 @@ GitHub releases include a versioned source archive covered by the release's `SHA
|
||||
VERSION="<release-version>"
|
||||
tar -xzf "pi-${VERSION}-source.tar.gz"
|
||||
cd "pi-${VERSION}"
|
||||
./scripts/build-binaries.sh --platform linux-x64 --out "$PWD/out"
|
||||
./scripts/build-binaries.sh --offline-model-data --platform linux-x64 --out "$PWD/out"
|
||||
```
|
||||
|
||||
The script installs dependencies, builds the monorepo, compiles the Bun executable, and stages its runtime assets. Package maintainers who provide dependencies separately can pass `--skip-install --skip-deps`.
|
||||
The source archive includes the generated provider model data used for the release. `--offline-model-data` builds with that snapshot instead of refreshing it from live provider catalogs. The script still installs dependencies, builds the monorepo, compiles the Bun executable, and stages its runtime assets. Package maintainers who provide dependencies separately can pass `--skip-install --skip-deps`.
|
||||
|
||||
## Supply-chain hardening
|
||||
|
||||
|
||||
Generated
+377
-794
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -26,10 +26,12 @@
|
||||
"check:model-data": "npm --prefix packages/ai run check:model-data",
|
||||
"generate:model-catalog": "npm --prefix packages/ai run generate-model-catalog",
|
||||
"diff:model-catalog": "node scripts/diff-model-catalog.mjs",
|
||||
"eval": "npm run eval --workspace=@earendil-works/pi-evals --",
|
||||
"check:model-catalog": "node scripts/publish-model-catalog.mjs --input .artifacts/model-catalog --dry-run",
|
||||
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
|
||||
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"test": "npm run test:scripts && npm run test --workspaces --if-present",
|
||||
"test:scripts": "node --test scripts/*.test.mjs",
|
||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
@@ -63,6 +65,7 @@
|
||||
},
|
||||
"version": "0.0.3",
|
||||
"overrides": {
|
||||
"protobufjs": "7.6.5",
|
||||
"rimraf": "6.1.2",
|
||||
"gaxios": {
|
||||
"rimraf": "6.1.2"
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.82.1] - 2026-07-25
|
||||
|
||||
## [0.82.0] - 2026-07-24
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Replaced `AgentHarness`'s `ExecutionEnv` dependency and context-free `AgentTool` inputs with application-defined `toolContext` values and context-aware `AgentHarnessTool` definitions.
|
||||
|
||||
### Added
|
||||
|
||||
- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`, including async bash execution preparation.
|
||||
|
||||
### Changed
|
||||
|
||||
- Aligned harness tool path handling, edit serialization, shell output capture, explicit non-inherited environments, and cross-platform process cleanup with coding-agent behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)).
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -43,6 +43,7 @@ Harness config is the latest runtime configuration set by the application or ext
|
||||
- thinking level
|
||||
- tools
|
||||
- active tool names
|
||||
- tool context source
|
||||
- resources
|
||||
- stream options
|
||||
- system prompt or system prompt provider
|
||||
@@ -66,6 +67,7 @@ A turn snapshot is the concrete state used for one LLM turn. It is created by `c
|
||||
- thinking level
|
||||
- all tools
|
||||
- active tools
|
||||
- resolved tool context
|
||||
- stream options
|
||||
- derived session id
|
||||
|
||||
@@ -73,8 +75,14 @@ Static option values are used directly. System-prompt provider callbacks are inv
|
||||
|
||||
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
|
||||
|
||||
`toolContext` is application-defined and required when the configured tools require a non-`undefined` context. A static value is reused, while a zero-argument sync or async provider is resolved once for each turn snapshot. Harness tools receive that resolved value when they execute. Individual tools can structurally require only the context fields they use.
|
||||
|
||||
Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot.
|
||||
|
||||
### Built-in tools
|
||||
|
||||
The package exports `createReadTool()`, `createWriteTool()`, `createEditTool()`, and `createBashTool()`. They perform filesystem and shell operations exclusively through the `ExecutionEnv` supplied in their tool context. Each tool structurally requires the shared `ExecutionToolContext`, containing `env: ExecutionEnv`; applications may provide additional fields. `createReadTool()` accepts an optional image processor for host-provided conversion and resizing without imposing an image-processing dependency on the agent package. `createBashTool()` accepts an async `prepare` hook that can mutate the command, working directory, environment, and environment-inheritance policy using the current tool context.
|
||||
|
||||
### Session
|
||||
|
||||
The session contains persisted entries only. Session reads return persisted state and do not include queued writes.
|
||||
@@ -266,7 +274,7 @@ Done:
|
||||
- Added `setTools(tools, activeToolNames?)`.
|
||||
- Added `setActiveTools(toolNames)`.
|
||||
- Invalid active tool names reject with `AgentHarnessError`.
|
||||
- Added generic app tool shape via `AgentHarness<TSkill, TPromptTemplate, TTool>`.
|
||||
- Added generic app tool and context shapes via `AgentHarness<TContext, TSkill, TPromptTemplate, TTool>`.
|
||||
- Exported `QueueMode` from core types.
|
||||
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
|
||||
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
# Pi Agent Architecture - Top-Down Overview
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Pi Agent is a **stateful, event-driven agent framework** built in TypeScript. It provides:
|
||||
|
||||
1. **Core Agent** - Low-level agent loop with message/tool streaming
|
||||
2. **Agent Harness** - High-level session management with persistence, branching, and compaction
|
||||
|
||||
Both layers follow the **same core pattern**: stream LLM response → execute tools → emit events → repeat.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION LAYER │
|
||||
│ • Creates Agent/AgentHarness instances │
|
||||
│ • Subscribes to events for UI updates │
|
||||
│ • Provides tools and model configuration │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────────┼─────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────────────┐ ┌─────────────────┐
|
||||
│ Agent (Core) │ │ Agent Harness (High-Level) │ │ Agent-Loop │
|
||||
│ │ │ │ │ │
|
||||
│ • State management │ │ • Session persistence │ │ • Turn │
|
||||
│ • Event streaming │ │ • Branching/compaction │ │ • Tool exec │
|
||||
│ • Steering/follow-up │ │ • Skills/templates │ │ • Message │
|
||||
│ queues │ │ • Tool context binding │ │ streaming │
|
||||
│ • Hook system │ │ • State snapshots │ │ │
|
||||
└────────────────────────┘ └────────────────────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────┐ ┌─────────────────┐
|
||||
│ LLM Provider API │ │ Session Repo │
|
||||
│ (via @earendil-works) │ │ (JSONL/ │
|
||||
└──────────────────────────┘ │ Memory) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. AgentMessage
|
||||
|
||||
```typescript
|
||||
type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]
|
||||
```
|
||||
|
||||
The unified message type that combines:
|
||||
- **LLM messages**: `user`, `assistant`, `toolResult` (from pi-ai)
|
||||
- **Custom messages**: Application-specific types (via declaration merging)
|
||||
|
||||
### 2. AgentEvent
|
||||
|
||||
```typescript
|
||||
type AgentEvent =
|
||||
| { type: "agent_start" }
|
||||
| { type: "agent_end"; messages: AgentMessage[] }
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
|
||||
| { type: "message_start"; message: AgentMessage }
|
||||
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||
| { type: "message_end"; message: AgentMessage }
|
||||
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
|
||||
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
|
||||
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean }
|
||||
```
|
||||
|
||||
**Event Flow per Turn:**
|
||||
```
|
||||
turn_start
|
||||
message_start (user prompt)
|
||||
message_end
|
||||
message_start (assistant streaming)
|
||||
message_update (multiple - as chunks arrive)
|
||||
message_end
|
||||
tool_execution_start (if tool calls present)
|
||||
tool_execution_update (if tool streams partial results)
|
||||
tool_execution_end
|
||||
turn_end
|
||||
```
|
||||
|
||||
### 3. AgentTool
|
||||
|
||||
```typescript
|
||||
interface AgentTool<TParameters extends TSchema, TDetails> {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: TSchema;
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Process Flow Diagrams
|
||||
|
||||
### Prompt Flow (High-Level)
|
||||
|
||||
```
|
||||
User Input
|
||||
│
|
||||
▼
|
||||
Agent.prompt("Hello")
|
||||
│
|
||||
├─► normalizePromptInput() → AgentMessage[]
|
||||
│
|
||||
├─► runWithLifecycle()
|
||||
│ ├─► Set isStreaming=true
|
||||
│ └─► Create abort controller
|
||||
│
|
||||
▼
|
||||
runAgentLoop()
|
||||
│
|
||||
├─► Emit: agent_start
|
||||
├─► Emit: turn_start
|
||||
├─► Emit: message_start/end (prompts)
|
||||
│
|
||||
▼
|
||||
runLoop() - Main Loop
|
||||
│
|
||||
├─► Check steering queue (drain if any)
|
||||
├─► Check follow-up queue (skip if not first turn)
|
||||
│
|
||||
▼
|
||||
streamAssistantResponse()
|
||||
│
|
||||
├─► transformContext() [optional]
|
||||
├─► convertToLlm() → Message[]
|
||||
├─► Build Context {systemPrompt, messages, tools}
|
||||
├─► Resolve API key
|
||||
├─► Call streamFn(model, context, options)
|
||||
│
|
||||
▼
|
||||
Assistant Message Stream
|
||||
│
|
||||
├─► message_start (assistant)
|
||||
├─► message_update (text chunks)
|
||||
├─► message_update (toolCall blocks)
|
||||
├─► message_end
|
||||
│
|
||||
▼
|
||||
executeToolCalls()
|
||||
│
|
||||
├─► Check if sequential/parallel execution
|
||||
├─► For each tool call:
|
||||
│ ├─► prepareToolCall()
|
||||
│ │ ├─► Find tool by name
|
||||
│ │ ├─► Validate arguments
|
||||
│ │ └─► beforeToolCall() hook
|
||||
│ │
|
||||
│ ├─► executePreparedToolCall()
|
||||
│ │ └─► tool.execute() with onUpdate callback
|
||||
│ │
|
||||
│ └─► finalizeExecutedToolCall()
|
||||
│ └─► afterToolCall() hook
|
||||
│
|
||||
├─► Emit: tool_execution_start/update/end
|
||||
└─► Emit: message_start/end (toolResult)
|
||||
│
|
||||
▼
|
||||
turn_end
|
||||
│
|
||||
├─► Check prepareNextTurn hook
|
||||
├─► Check shouldStopAfterTurn hook
|
||||
├─► Drain steering queue
|
||||
└─► Drain follow-up queue
|
||||
│
|
||||
├─► If steering/follow-up exists → repeat loop
|
||||
└─► If no more messages → agent_end
|
||||
```
|
||||
|
||||
### Tool Execution Flow (Detailed)
|
||||
|
||||
```
|
||||
Tool Call from LLM
|
||||
│
|
||||
▼
|
||||
prepareToolCall()
|
||||
│
|
||||
├─► Find tool in currentContext.tools
|
||||
│ └─► If not found → immediate error
|
||||
│
|
||||
├─► prepareToolCallArguments() [optional]
|
||||
│
|
||||
├─► validateToolArguments()
|
||||
│ └─► If invalid → immediate error
|
||||
│
|
||||
└─► beforeToolCall() hook
|
||||
├─► Return {block: true} → error
|
||||
└─► Continue
|
||||
│
|
||||
▼
|
||||
executePreparedToolCall()
|
||||
│
|
||||
├─► Call tool.execute() with onUpdate callback
|
||||
│ └─► tool calls onUpdate(partialResult) during execution
|
||||
│
|
||||
├─► onUpdate() → emit tool_execution_update
|
||||
└─► Return {result, isError}
|
||||
│
|
||||
▼
|
||||
finalizeExecutedToolCall()
|
||||
│
|
||||
└─► afterToolCall() hook
|
||||
├─► Override content/details/usage/terminate
|
||||
└─► Return {toolCall, result, isError}
|
||||
│
|
||||
▼
|
||||
emitToolExecutionEnd()
|
||||
│
|
||||
└─► Emit: tool_execution_end
|
||||
│
|
||||
▼
|
||||
createToolResultMessage()
|
||||
│
|
||||
└─► Create ToolResultMessage with:
|
||||
├─► toolCallId
|
||||
├─► toolName
|
||||
├─► content
|
||||
├─► details
|
||||
├─► usage
|
||||
└─► isError
|
||||
│
|
||||
▼
|
||||
emitToolResultMessage()
|
||||
│
|
||||
├─► Emit: message_start
|
||||
└─► Emit: message_end
|
||||
```
|
||||
|
||||
### Session Persistence Flow
|
||||
|
||||
```
|
||||
AgentHarness.handleAgentEvent()
|
||||
│
|
||||
├─► message_end → session.appendMessage()
|
||||
│ └─► Storage: write entry to JSONL file
|
||||
│
|
||||
├─► turn_end → flushPendingSessionWrites()
|
||||
│ ├─► Write all pending entries
|
||||
│ ├─► Emit: save_point
|
||||
│ └─► session.getStorage().setLeafId()
|
||||
│
|
||||
└─► agent_end → flushPendingSessionWrites()
|
||||
├─► Write leaf entry pointing to last message
|
||||
└─► Emit: settled
|
||||
│
|
||||
▼
|
||||
Session Tree Structure:
|
||||
root
|
||||
├─► message (user prompt #1)
|
||||
├─► message (assistant #1)
|
||||
├─► tool_result (result #1)
|
||||
├─► turn_end
|
||||
├─► message (user prompt #2)
|
||||
├─► message (assistant #2)
|
||||
├─► compaction (summary of history)
|
||||
├─► message (assistant continues)
|
||||
└─► leaf → points to current head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hook System
|
||||
|
||||
### Agent-Level Hooks (agent-loop.ts)
|
||||
|
||||
```typescript
|
||||
interface AgentLoopConfig {
|
||||
// Message transformation
|
||||
convertToLlm: (messages: AgentMessage[]) => Message[]
|
||||
transformContext?: (messages: AgentMessage[]) => AgentMessage[]
|
||||
|
||||
// Lifecycle hooks
|
||||
beforeToolCall?: (context: BeforeToolCallContext) => BeforeToolCallResult
|
||||
afterToolCall?: (context: AfterToolCallContext) => AfterToolCallResult
|
||||
shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean
|
||||
prepareNextTurn?: (context: PrepareNextTurnContext) => AgentLoopTurnUpdate
|
||||
|
||||
// Queue draining
|
||||
getSteeringMessages?: () => AgentMessage[]
|
||||
getFollowUpMessages?: () => AgentMessage[]
|
||||
}
|
||||
```
|
||||
|
||||
### Harness-Level Hooks (agent-harness.ts)
|
||||
|
||||
```typescript
|
||||
// Hook types in AgentHarnessEventResultMap:
|
||||
type HookName =
|
||||
| "before_agent_start"
|
||||
| "context"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "session_before_compact"
|
||||
| "session_before_tree"
|
||||
| "before_provider_request"
|
||||
| "before_provider_payload"
|
||||
```
|
||||
|
||||
**Hook Execution Order per Turn:**
|
||||
```
|
||||
1. before_agent_start (harness)
|
||||
2. context (harness) → transformContext
|
||||
3. streamAssistantResponse
|
||||
├─► Before provider request (harness)
|
||||
├─► convertToLlm (agent)
|
||||
└─► LLM call
|
||||
4. For each tool call:
|
||||
├─► tool_call (harness) → beforeToolCall
|
||||
├─► Execute tool
|
||||
└─► tool_result (harness) → afterToolCall
|
||||
5. turn_end
|
||||
6. shouldStopAfterTurn (agent)
|
||||
7. prepareNextTurn (agent)
|
||||
8. Drain steering/follow-up queues
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ AGENT LIFECYCLE - DATA FLOW │
|
||||
├────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 1. INPUT │
|
||||
│ • prompt("Hello") → normalizePromptInput() │
|
||||
│ → AgentMessage[] │
|
||||
│ 2. INITIATE │
|
||||
│ • createMutableAgentState() │
|
||||
│ • runWithLifecycle() │
|
||||
│ 3. LOOP CONTROL │
|
||||
│ • runLoop() │
|
||||
│ ├─► Steering queue? → drain and inject │
|
||||
│ └─► Follow-up queue? (after first turn) │
|
||||
│ 4. LLM STREAMING │
|
||||
│ • transformContext() [optional] │
|
||||
│ • convertToLlm() │
|
||||
│ • streamFn() │
|
||||
│ → AssistantMessage stream (text + toolCalls) │
|
||||
│ 5. TOOL EXECUTION │
|
||||
│ • executeToolCalls() │
|
||||
│ ├─► prepareToolCall() │
|
||||
│ │ ├─► beforeToolCall() hook │
|
||||
│ │ └─► Validate args │
|
||||
│ ├─► executePreparedToolCall() │
|
||||
│ │ └─► tool.execute() │
|
||||
│ └─► finalizeExecutedToolCall() │
|
||||
│ └─► afterToolCall() hook │
|
||||
│ 6. UPDATE STATE │
|
||||
│ • Push assistant message to state.messages │
|
||||
│ • Push toolResult messages to state.messages │
|
||||
│ 7. TERMINATION CHECK │
|
||||
│ • shouldStopAfterTurn? → exit │
|
||||
│ • prepareNextTurn? → update context/model │
|
||||
│ • Drain steering/follow-up → continue │
|
||||
│ 8. FINISH │
|
||||
│ • emit agent_end │
|
||||
│ • finishRun() → reset isStreaming │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Design Patterns
|
||||
|
||||
### 1. Event-Driven Architecture
|
||||
|
||||
- All external communication via `AgentEvent` stream
|
||||
- Hooks can be async and are awaited in order
|
||||
- Abort signal propagated through all operations
|
||||
|
||||
### 2. State Isolation
|
||||
|
||||
- `AgentState` is read-only externally
|
||||
- `AgentHarness` snapshots state per turn
|
||||
- Context transforms return new arrays (immutability)
|
||||
|
||||
### 3. Layered Abstraction
|
||||
|
||||
```
|
||||
Low-level (agent-loop.ts)
|
||||
• Pure async iteration
|
||||
• No session management
|
||||
• No tool context binding
|
||||
|
||||
High-level (agent-harness.ts)
|
||||
• Session persistence
|
||||
• Branching/compaction
|
||||
• Hook system for customization
|
||||
```
|
||||
|
||||
### 4. Extensibility Points
|
||||
|
||||
- **Custom messages**: Extend `CustomAgentMessages` interface
|
||||
- **Custom hooks**: Add handlers via `subscribe()`/`on()`
|
||||
- **Tool context**: Pass `toolContext` to harness constructor
|
||||
- **Storage**: Implement `SessionStorage` interface
|
||||
|
||||
---
|
||||
|
||||
## Learning Path
|
||||
|
||||
1. **Start with types.ts** - Understand `AgentMessage`, `AgentEvent`, `AgentTool`
|
||||
2. **Read agent-loop.ts** - See how messages flow through the loop
|
||||
3. **Study agent.ts** - See how Agent wraps the loop with state management
|
||||
4. **Read agent-harness.ts** - See how session management hooks into the loop
|
||||
5. **Explore session/* files** - Understand persistence and branching
|
||||
6. **Study tools/* files** - See concrete tool implementations
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
See individual markdown files in this folder for:
|
||||
- `AGENT-LOOP-DETAILED.md` - Deep dive into the agent loop
|
||||
- `HOOK-SYSTEM.md` - Complete hook documentation
|
||||
- `SESSION-ARCHITECTURE.md` - Session persistence details
|
||||
- `TOOL-EXECUTION.md` - Tool execution mechanics
|
||||
@@ -0,0 +1,697 @@
|
||||
# Agent Loop Deep Dive
|
||||
|
||||
## Overview
|
||||
|
||||
The `agent-loop.ts` file contains the **core async iteration logic** that drives the agent. It's intentionally low-level and stateless - it takes a snapshot of context and drives it to completion.
|
||||
|
||||
---
|
||||
|
||||
## Core Functions
|
||||
|
||||
### 1. `runAgentLoop()`
|
||||
|
||||
**Purpose**: Start a new agent run with initial prompt messages.
|
||||
|
||||
```typescript
|
||||
async function runAgentLoop(
|
||||
prompts: AgentMessage[],
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
emit: AgentEventSink,
|
||||
signal: AbortSignal | undefined,
|
||||
streamFn: StreamFn,
|
||||
): Promise<AgentMessage[]>
|
||||
```
|
||||
|
||||
**Flow**:
|
||||
```
|
||||
1. Create newMessages = [...prompts]
|
||||
2. Append prompts to context.messages
|
||||
3. Emit: agent_start
|
||||
4. Emit: turn_start
|
||||
5. For each prompt:
|
||||
- Emit: message_start
|
||||
- Emit: message_end
|
||||
6. Call: runLoop() - main iteration logic
|
||||
7. Return: newMessages
|
||||
```
|
||||
|
||||
### 2. `runAgentLoopContinue()`
|
||||
|
||||
**Purpose**: Continue from existing context (no new prompts).
|
||||
|
||||
```typescript
|
||||
async function runAgentLoopContinue(
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
emit: AgentEventSink,
|
||||
signal: AbortSignal | undefined,
|
||||
streamFn: StreamFn,
|
||||
): Promise<AgentMessage[]>
|
||||
```
|
||||
|
||||
**Constraints**:
|
||||
- Last message must convert to `user` or `toolResult`
|
||||
- Throws if context is empty or last message is `assistant`
|
||||
|
||||
**Flow**:
|
||||
```
|
||||
1. Validate context (non-empty, last message is not assistant)
|
||||
2. Create newMessages = [] (empty - we continue)
|
||||
3. Emit: agent_start
|
||||
4. Emit: turn_start
|
||||
5. Call: runLoop()
|
||||
6. Return: newMessages
|
||||
```
|
||||
|
||||
### 3. `runLoop()` - The Heart of the Agent
|
||||
|
||||
**Purpose**: Main iteration loop that drives conversation.
|
||||
|
||||
```typescript
|
||||
async function runLoop(
|
||||
initialContext: AgentContext,
|
||||
newMessages: AgentMessage[],
|
||||
initialConfig: AgentLoopConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
emit: AgentEventSink,
|
||||
streamFunction: StreamFn,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**Structure**:
|
||||
|
||||
```typescript
|
||||
async function runLoop(...) {
|
||||
let currentContext = initialContext;
|
||||
let config = initialConfig;
|
||||
let firstTurn = true;
|
||||
let pendingMessages: AgentMessage[] = [];
|
||||
|
||||
// OUTER LOOP: Handles follow-up messages
|
||||
while (true) {
|
||||
let hasMoreToolCalls = true;
|
||||
|
||||
// INNER LOOP: Handles tool calls and steering
|
||||
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
||||
if (!firstTurn) {
|
||||
await emit({ type: "turn_start" });
|
||||
} else {
|
||||
firstTurn = false;
|
||||
}
|
||||
|
||||
// 1. Process pending messages (steering/follow-up)
|
||||
if (pendingMessages.length > 0) {
|
||||
for (const message of pendingMessages) {
|
||||
await emit({ type: "message_start", message });
|
||||
await emit({ type: "message_end", message });
|
||||
currentContext.messages.push(message);
|
||||
newMessages.push(message);
|
||||
}
|
||||
pendingMessages = [];
|
||||
}
|
||||
|
||||
// 2. Stream assistant response
|
||||
const message = await streamAssistantResponse(...);
|
||||
newMessages.push(message);
|
||||
|
||||
// 3. Check for errors
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
await emit({ type: "turn_end", message, toolResults: [] });
|
||||
await emit({ type: "agent_end", messages: newMessages });
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Execute tool calls
|
||||
const toolCalls = message.content.filter(c => c.type === "toolCall");
|
||||
const toolResults: ToolResultMessage[] = [];
|
||||
hasMoreToolCalls = false;
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
const executedBatch = await executeToolCalls(...);
|
||||
toolResults.push(...executedBatch.messages);
|
||||
hasMoreToolCalls = !executedBatch.terminate;
|
||||
|
||||
for (const result of toolResults) {
|
||||
currentContext.messages.push(result);
|
||||
newMessages.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Emit turn_end
|
||||
await emit({ type: "turn_end", message, toolResults });
|
||||
|
||||
// 6. Prepare next turn
|
||||
const nextTurnContext = { message, toolResults, context, newMessages };
|
||||
const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
|
||||
|
||||
if (nextTurnSnapshot) {
|
||||
currentContext = nextTurnSnapshot.context ?? currentContext;
|
||||
config = { ...config, model: nextTurnSnapshot.model };
|
||||
}
|
||||
|
||||
// 7. Check termination
|
||||
if (await config.shouldStopAfterTurn?.(...)) {
|
||||
await emit({ type: "agent_end", messages: newMessages });
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. Drain steering queue
|
||||
pendingMessages = (await config.getSteeringMessages?.()) || [];
|
||||
}
|
||||
|
||||
// Outer loop: Check for follow-up messages
|
||||
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
|
||||
if (followUpMessages.length > 0) {
|
||||
pendingMessages = followUpMessages;
|
||||
continue; // Back to inner loop
|
||||
}
|
||||
|
||||
// No more messages - exit
|
||||
break;
|
||||
}
|
||||
|
||||
await emit({ type: "agent_end", messages: newMessages });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message Streaming
|
||||
|
||||
### `streamAssistantResponse()`
|
||||
|
||||
**Purpose**: Stream assistant response from LLM provider.
|
||||
|
||||
```typescript
|
||||
async function streamAssistantResponse(
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
emit: AgentEventSink,
|
||||
streamFunction: StreamFn,
|
||||
): Promise<AssistantMessage>
|
||||
```
|
||||
|
||||
**Flow**:
|
||||
|
||||
```
|
||||
1. Apply transformContext() if configured
|
||||
├─► messages = await config.transformContext(messages)
|
||||
└─► Returns new AgentMessage[]
|
||||
|
||||
2. Convert to LLM format
|
||||
├─► llmMessages = await config.convertToLlm(messages)
|
||||
└─► Returns Message[] (filters custom messages)
|
||||
|
||||
3. Build LLM Context
|
||||
Context = {
|
||||
systemPrompt: context.systemPrompt,
|
||||
messages: llmMessages,
|
||||
tools: context.tools
|
||||
}
|
||||
|
||||
4. Resolve API key
|
||||
├─► Get key from getApiKey() hook
|
||||
└─► Fallback to config.apiKey
|
||||
|
||||
5. Call streamFn()
|
||||
├─► StreamFn(model, context, options)
|
||||
└─► Returns AssistantMessageEventStream
|
||||
|
||||
6. Process stream events
|
||||
for await (const event of response) {
|
||||
switch (event.type) {
|
||||
case "start":
|
||||
// Initialize partial message
|
||||
partialMessage = event.partial
|
||||
context.messages.push(partialMessage)
|
||||
emit({ type: "message_start", message })
|
||||
|
||||
case "text_start" | "text_delta" | "text_end":
|
||||
case "thinking_start" | "thinking_delta" | "thinking_end":
|
||||
case "toolcall_start" | "toolcall_delta" | "toolcall_end":
|
||||
// Update partial message
|
||||
partialMessage = event.partial
|
||||
emit({ type: "message_update", ... })
|
||||
|
||||
case "done" | "error":
|
||||
const finalMessage = await response.result()
|
||||
emit({ type: "message_end", message })
|
||||
return finalMessage
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Execution
|
||||
|
||||
### Sequential vs Parallel
|
||||
|
||||
**Sequential Mode**:
|
||||
- Each tool call prepared, executed, finalized before next
|
||||
- Emit `tool_execution_end` immediately after each
|
||||
- Tool results in source order
|
||||
|
||||
**Parallel Mode**:
|
||||
- All tool calls prepared sequentially
|
||||
- Allowed tools execute concurrently
|
||||
- Emit `tool_execution_end` in completion order
|
||||
- Tool results in source order
|
||||
|
||||
### `executeToolCalls()`
|
||||
|
||||
```typescript
|
||||
async function executeToolCalls(...): Promise<ExecutedToolCallBatch> {
|
||||
const toolCalls = assistantMessage.content.filter(c => c.type === "toolCall");
|
||||
|
||||
// Check if any tool requires sequential execution
|
||||
const hasSequentialToolCall = toolCalls.some(tc => {
|
||||
const tool = currentContext.tools?.find(t => t.name === tc.name);
|
||||
return tool?.executionMode === "sequential";
|
||||
});
|
||||
|
||||
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
|
||||
return executeToolCallsSequential(...);
|
||||
}
|
||||
|
||||
return executeToolCallsParallel(...);
|
||||
}
|
||||
```
|
||||
|
||||
### `executeToolCallsSequential()`
|
||||
|
||||
```typescript
|
||||
async function executeToolCallsSequential(...): Promise<ExecutedToolCallBatch> {
|
||||
const finalizedCalls: FinalizedToolCallOutcome[] = [];
|
||||
const messages: ToolResultMessage[] = [];
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
// 1. Prepare
|
||||
const preparation = await prepareToolCall(...);
|
||||
|
||||
let finalized: FinalizedToolCallOutcome;
|
||||
if (preparation.kind === "immediate") {
|
||||
// Validation/permission hook blocked execution
|
||||
finalized = { toolCall, result: preparation.result, isError: preparation.isError };
|
||||
} else {
|
||||
// Execute
|
||||
const executed = await executePreparedToolCall(preparation, signal, emit);
|
||||
finalized = await finalizeExecutedToolCall(...);
|
||||
}
|
||||
|
||||
// 2. Emit
|
||||
await emitToolExecutionEnd(finalized, emit);
|
||||
const toolResultMessage = createToolResultMessage(finalized);
|
||||
await emitToolResultMessage(toolResultMessage, emit);
|
||||
|
||||
finalizedCalls.push(finalized);
|
||||
messages.push(toolResultMessage);
|
||||
|
||||
if (signal?.aborted) break;
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
terminate: shouldTerminateToolBatch(finalizedCalls)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### `executeToolCallsParallel()`
|
||||
|
||||
```typescript
|
||||
async function executeToolCallsParallel(...): Promise<ExecutedToolCallBatch> {
|
||||
const finalizedCalls: FinalizedToolCallEntry[] = [];
|
||||
|
||||
// Phase 1: Prepare all tool calls
|
||||
for (const toolCall of toolCalls) {
|
||||
const preparation = await prepareToolCall(...);
|
||||
|
||||
if (preparation.kind === "immediate") {
|
||||
// Blocked or error - execute immediately
|
||||
const finalized = {
|
||||
toolCall,
|
||||
result: preparation.result,
|
||||
isError: preparation.isError
|
||||
};
|
||||
await emitToolExecutionEnd(finalized, emit);
|
||||
finalizedCalls.push(finalized);
|
||||
} else {
|
||||
// Schedule for concurrent execution
|
||||
finalizedCalls.push(async () => {
|
||||
const executed = await executePreparedToolCall(preparation, signal, emit);
|
||||
const finalized = await finalizeExecutedToolCall(...);
|
||||
await emitToolExecutionEnd(finalized, emit);
|
||||
return finalized;
|
||||
});
|
||||
}
|
||||
|
||||
if (signal?.aborted) break;
|
||||
}
|
||||
|
||||
// Phase 2: Execute concurrent tools and collect results
|
||||
const orderedFinalizedCalls = await Promise.all(
|
||||
finalizedCalls.map(entry => typeof entry === "function" ? entry() : Promise.resolve(entry))
|
||||
);
|
||||
|
||||
// Phase 3: Emit tool result messages in source order
|
||||
const messages: ToolResultMessage[] = [];
|
||||
for (const finalized of orderedFinalizedCalls) {
|
||||
const toolResultMessage = createToolResultMessage(finalized);
|
||||
await emitToolResultMessage(toolResultMessage, emit);
|
||||
messages.push(toolResultMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
terminate: shouldTerminateToolBatch(orderedFinalizedCalls)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Preparation Flow
|
||||
|
||||
### `prepareToolCall()`
|
||||
|
||||
```typescript
|
||||
async function prepareToolCall(...): Promise<PreparedToolCall | ImmediateToolCallOutcome> {
|
||||
// 1. Find tool
|
||||
const tool = currentContext.tools?.find(t => t.name === toolCall.name);
|
||||
if (!tool) {
|
||||
return {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult(`Tool ${toolCall.name} not found`),
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Prepare arguments (optional shim)
|
||||
const preparedToolCall = prepareToolCallArguments(tool, toolCall);
|
||||
|
||||
// 3. Validate arguments
|
||||
const validatedArgs = validateToolArguments(tool, preparedToolCall);
|
||||
|
||||
// 4. beforeToolCall hook
|
||||
if (config.beforeToolCall) {
|
||||
const beforeResult = await config.beforeToolCall(
|
||||
{ assistantMessage, toolCall, args: validatedArgs, context: currentContext },
|
||||
signal
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
return immediateError("Operation aborted");
|
||||
}
|
||||
|
||||
if (beforeResult?.block) {
|
||||
return {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return immediateError("Operation aborted");
|
||||
}
|
||||
|
||||
// 5. Return prepared call for execution
|
||||
return {
|
||||
kind: "prepared",
|
||||
toolCall,
|
||||
tool,
|
||||
args: validatedArgs
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult(error.message),
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Execution Flow
|
||||
|
||||
### `executePreparedToolCall()`
|
||||
|
||||
```typescript
|
||||
async function executePreparedToolCall(
|
||||
prepared: PreparedToolCall,
|
||||
signal: AbortSignal | undefined,
|
||||
emit: AgentEventSink,
|
||||
): Promise<ExecutedToolCallOutcome> {
|
||||
const updateEvents: Promise<void>[] = [];
|
||||
let acceptingUpdates = true;
|
||||
|
||||
try {
|
||||
// Call tool.execute() with onUpdate callback
|
||||
const result = await prepared.tool.execute(
|
||||
prepared.toolCall.id,
|
||||
prepared.args,
|
||||
signal,
|
||||
(partialResult) => {
|
||||
if (!acceptingUpdates) return;
|
||||
|
||||
// Buffer update events to emit in order
|
||||
updateEvents.push(
|
||||
Promise.resolve(
|
||||
emit({
|
||||
type: "tool_execution_update",
|
||||
toolCallId: prepared.toolCall.id,
|
||||
toolName: prepared.toolCall.name,
|
||||
args: prepared.toolCall.arguments,
|
||||
partialResult
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
acceptingUpdates = false;
|
||||
await Promise.all(updateEvents); // Wait for all updates to flush
|
||||
return { result, isError: false };
|
||||
} catch (error) {
|
||||
acceptingUpdates = false;
|
||||
await Promise.all(updateEvents);
|
||||
return {
|
||||
result: createErrorToolResult(error.message),
|
||||
isError: true
|
||||
};
|
||||
} finally {
|
||||
acceptingUpdates = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Finalization Flow
|
||||
|
||||
### `finalizeExecutedToolCall()`
|
||||
|
||||
```typescript
|
||||
async function finalizeExecutedToolCall(
|
||||
currentContext: AgentContext,
|
||||
assistantMessage: AssistantMessage,
|
||||
prepared: PreparedToolCall,
|
||||
executed: ExecutedToolCallOutcome,
|
||||
config: AgentLoopConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<FinalizedToolCallOutcome> {
|
||||
let result = executed.result;
|
||||
let isError = executed.isError;
|
||||
|
||||
// afterToolCall hook - can override result
|
||||
if (config.afterToolCall) {
|
||||
try {
|
||||
const afterResult = await config.afterToolCall(
|
||||
{
|
||||
assistantMessage,
|
||||
toolCall: prepared.toolCall,
|
||||
args: prepared.args,
|
||||
result,
|
||||
isError,
|
||||
context: currentContext
|
||||
},
|
||||
signal
|
||||
);
|
||||
|
||||
if (afterResult) {
|
||||
// Field-by-field override (no deep merge)
|
||||
result = {
|
||||
...result,
|
||||
content: afterResult.content ?? result.content,
|
||||
details: afterResult.details ?? result.details,
|
||||
usage: afterResult.usage ?? result.usage,
|
||||
terminate: afterResult.terminate ?? result.terminate,
|
||||
};
|
||||
isError = afterResult.isError ?? isError;
|
||||
}
|
||||
} catch (error) {
|
||||
result = createErrorToolResult(error.message);
|
||||
isError = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolCall: prepared.toolCall,
|
||||
result,
|
||||
isError
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Termination Logic
|
||||
|
||||
### `shouldTerminateToolBatch()`
|
||||
|
||||
```typescript
|
||||
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
|
||||
return finalizedCalls.length > 0 &&
|
||||
finalizedCalls.every(f => f.result.terminate === true);
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- Only terminates if **ALL** tool calls set `terminate: true`
|
||||
- Allows partial tool execution while signaling early termination
|
||||
|
||||
### `shouldStopAfterTurn()`
|
||||
|
||||
Called after `turn_end`, before checking steering/follow-up queues:
|
||||
|
||||
```typescript
|
||||
if (await config.shouldStopAfterTurn?.({
|
||||
message,
|
||||
toolResults,
|
||||
context: currentContext,
|
||||
newMessages
|
||||
})) {
|
||||
await emit({ type: "agent_end", messages: newMessages });
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**Common use cases**:
|
||||
- Stop before context gets too large
|
||||
- Stop after completing a specific goal
|
||||
- Stop on error
|
||||
|
||||
---
|
||||
|
||||
## Queue Management
|
||||
|
||||
### Steering Queue
|
||||
|
||||
**Purpose**: Interrupt agent while it's working.
|
||||
|
||||
**When drained**: After each turn ends, before next LLM call.
|
||||
|
||||
**Mode**: `"all"` or `"one-at-a-time"`
|
||||
|
||||
```typescript
|
||||
// Example: Steer agent mid-execution
|
||||
agent.steer("Wait, let me check something else first");
|
||||
agent.steer("Also, use a different approach");
|
||||
```
|
||||
|
||||
### Follow-up Queue
|
||||
|
||||
**Purpose**: Queue messages for after agent would naturally stop.
|
||||
|
||||
**When drained**: When agent has no more tool calls and no steering messages.
|
||||
|
||||
**Mode**: `"all"` or `"one-at-a-time"`
|
||||
|
||||
```typescript
|
||||
// Example: Follow up after agent finishes
|
||||
agent.followUp("Now summarize what you did");
|
||||
agent.followUp("What's next?");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Truncated Tool Calls
|
||||
|
||||
```typescript
|
||||
async function failToolCallsFromTruncatedMessage(
|
||||
toolCalls: AgentToolCall[],
|
||||
emit: AgentEventSink
|
||||
): Promise<ExecutedToolCallBatch> {
|
||||
// All tool calls from truncated assistant message fail
|
||||
// Reason: tool call arguments may be incomplete
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
await emit({ type: "tool_execution_start", ... });
|
||||
await emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: createErrorToolResult(
|
||||
`Tool call was not executed: response hit output token limit, arguments may be truncated.`
|
||||
),
|
||||
isError: true
|
||||
});
|
||||
}
|
||||
|
||||
return { messages: [], terminate: false };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Abort Handling
|
||||
|
||||
All async operations respect the abort signal:
|
||||
|
||||
```typescript
|
||||
// In prepareToolCall
|
||||
if (signal?.aborted) {
|
||||
return immediateError("Operation aborted");
|
||||
}
|
||||
|
||||
// In executePreparedToolCall
|
||||
const result = await tool.execute(id, args, signal, onUpdate);
|
||||
// Tool can check signal.aborted and cancel long-running operations
|
||||
|
||||
// In streamAssistantResponse
|
||||
for await (const event of response) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Aborted");
|
||||
}
|
||||
// Process event
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The agent loop is a **two-level iterator**:
|
||||
|
||||
1. **Outer loop**: Handles follow-up messages after agent would stop
|
||||
2. **Inner loop**: Handles tool calls and steering messages
|
||||
|
||||
Each iteration:
|
||||
- Streams assistant response (LLM)
|
||||
- Executes tool calls (sequential or parallel)
|
||||
- Emits events for UI updates
|
||||
- Updates context with new messages
|
||||
|
||||
The loop terminates when:
|
||||
- `shouldStopAfterTurn()` returns true
|
||||
- Error or abort occurs
|
||||
- No more steering/follow-up messages
|
||||
@@ -0,0 +1,792 @@
|
||||
# Hook System Reference
|
||||
|
||||
## Overview
|
||||
|
||||
The hook system provides **extensibility points** at both the Agent and AgentHarness layers. Hooks are asynchronous, can be cancelled via abort signal, and run in subscription order.
|
||||
|
||||
---
|
||||
|
||||
## Hook Categories
|
||||
|
||||
### 1. Message Transformation Hooks
|
||||
|
||||
#### `convertToLlm`
|
||||
|
||||
**Location**: `AgentLoopConfig.convertToLlm`
|
||||
|
||||
**Purpose**: Convert `AgentMessage[]` to `Message[]` before LLM call.
|
||||
|
||||
**When called**: Just before each LLM request.
|
||||
|
||||
**Key contract**:
|
||||
- Must not throw or reject
|
||||
- Must handle all `AgentMessage` variants
|
||||
- Filter out UI-only messages (notifications, artifacts, etc.)
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
convertToLlm: (messages) => messages.filter(m =>
|
||||
m.role === "user" ||
|
||||
m.role === "assistant" ||
|
||||
m.role === "toolResult"
|
||||
)
|
||||
```
|
||||
|
||||
#### `transformContext`
|
||||
|
||||
**Location**: `AgentLoopConfig.transformContext` (optional)
|
||||
|
||||
**Purpose**: Manipulate context before LLM conversion.
|
||||
|
||||
**When called**: Before `convertToLlm`.
|
||||
|
||||
**Use cases**:
|
||||
- Context window management (pruning old messages)
|
||||
- Injecting external context
|
||||
- Message deduplication
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
transformContext: async (messages, signal) => {
|
||||
if (estimateTokens(messages) > MAX_TOKENS) {
|
||||
return pruneOldMessages(messages);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Lifecycle Hooks
|
||||
|
||||
#### `beforeToolCall`
|
||||
|
||||
**Location**: `AgentLoopConfig.beforeToolCall` (optional)
|
||||
|
||||
**Context**:
|
||||
```typescript
|
||||
interface BeforeToolCallContext {
|
||||
assistantMessage: AssistantMessage;
|
||||
toolCall: AgentToolCall;
|
||||
args: unknown; // Validated against tool schema
|
||||
context: AgentContext;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
interface BeforeToolCallResult {
|
||||
block?: boolean; // If true, tool won't execute
|
||||
reason?: string; // Error message shown in tool result
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: After args validation, before tool execution.
|
||||
|
||||
**Use cases**:
|
||||
- Permission checks (user approval)
|
||||
- Rate limiting
|
||||
- Context-aware tool blocking
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
beforeToolCall: async ({ toolCall, args, context }, signal) => {
|
||||
if (toolCall.name === "bash" && signal?.aborted) {
|
||||
return { block: true, reason: "Operation aborted" };
|
||||
}
|
||||
return undefined; // Allow execution
|
||||
}
|
||||
```
|
||||
|
||||
#### `afterToolCall`
|
||||
|
||||
**Location**: `AgentLoopConfig.afterToolCall` (optional)
|
||||
|
||||
**Context**:
|
||||
```typescript
|
||||
interface AfterToolCallContext {
|
||||
assistantMessage: AssistantMessage;
|
||||
toolCall: AgentToolCall;
|
||||
args: unknown;
|
||||
result: AgentToolResult<any>;
|
||||
isError: boolean;
|
||||
context: AgentContext;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
interface AfterToolCallResult {
|
||||
content?: (TextContent | ImageContent)[];
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
usage?: Usage;
|
||||
terminate?: boolean; // Early termination hint
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: After tool execution, before emitting `tool_execution_end`.
|
||||
|
||||
**Use cases**:
|
||||
- Modify tool results (redact sensitive data)
|
||||
- Update usage tracking
|
||||
- Trigger early termination
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
afterToolCall: async ({ result }, signal) => {
|
||||
// Redact sensitive content
|
||||
const content = result.content.map(c => {
|
||||
if (c.type === "text") {
|
||||
return { ...c, text: redactSecrets(c.text) };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
return { content };
|
||||
}
|
||||
```
|
||||
|
||||
#### `shouldStopAfterTurn`
|
||||
|
||||
**Location**: `AgentLoopConfig.shouldStopAfterTurn` (optional)
|
||||
|
||||
**Context**:
|
||||
```typescript
|
||||
interface ShouldStopAfterTurnContext {
|
||||
message: AssistantMessage;
|
||||
toolResults: ToolResultMessage[];
|
||||
context: AgentContext;
|
||||
newMessages: AgentMessage[];
|
||||
}
|
||||
```
|
||||
|
||||
**Return**: `boolean`
|
||||
|
||||
**When called**: After `turn_end`, before draining steering/follow-up queues.
|
||||
|
||||
**Use cases**:
|
||||
- Stop when goal achieved
|
||||
- Stop before context gets too large
|
||||
- Error recovery
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
shouldStopAfterTurn: async ({ message, toolResults, context }) => {
|
||||
// Stop if model indicates task complete
|
||||
if (message.content.some(c =>
|
||||
c.type === "text" && c.text.includes("TASK_COMPLETE"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stop if context too large
|
||||
if (estimateTokens(context.messages) > MAX_TOKENS * 0.8) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
#### `prepareNextTurn`
|
||||
|
||||
**Location**: `AgentLoopConfig.prepareNextTurn` (optional)
|
||||
|
||||
**Context**: Same as `ShouldStopAfterTurnContext`
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
interface AgentLoopTurnUpdate {
|
||||
context?: AgentContext;
|
||||
model?: Model<any>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: After `shouldStopAfterTurn`, if not stopping.
|
||||
|
||||
**Use cases**:
|
||||
- Update model based on conversation context
|
||||
- Switch thinking level
|
||||
- Inject new context
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
prepareNextTurn: async ({ message, toolResults, context }) => {
|
||||
// Switch to higher reasoning for complex tasks
|
||||
if (toolResults.length > 3) {
|
||||
return {
|
||||
thinkingLevel: "high"
|
||||
};
|
||||
}
|
||||
|
||||
return undefined; // Keep current config
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Queue Draining Hooks
|
||||
|
||||
#### `getSteeringMessages`
|
||||
|
||||
**Location**: `AgentLoopConfig.getSteeringMessages` (optional)
|
||||
|
||||
**Return**: `Promise<AgentMessage[]>`
|
||||
|
||||
**When called**: After turn ends, before next LLM call.
|
||||
|
||||
**Purpose**: Inject messages to interrupt agent mid-workflow.
|
||||
|
||||
**Mode**: `"all"` or `"one-at-a-time"` (controls how many messages injected)
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
getSteeringMessages: async () => {
|
||||
// Check for user input while agent is working
|
||||
if (userQueue.length > 0) {
|
||||
return userQueue.splice(0, 1); // one-at-a-time mode
|
||||
}
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
#### `getFollowUpMessages`
|
||||
|
||||
**Location**: `AgentLoopConfig.getFollowUpMessages` (optional)
|
||||
|
||||
**Return**: `Promise<AgentMessage[]>`
|
||||
|
||||
**When called**: When agent would stop (no more tool calls, no steering messages).
|
||||
|
||||
**Purpose**: Queue messages for after agent finishes.
|
||||
|
||||
**Mode**: `"all"` or `"one-at-a-time"`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
getFollowUpMessages: async () => {
|
||||
// Check if user typed while agent was working
|
||||
if (followUpQueue.length > 0) {
|
||||
return followUpQueue.splice(0, 1);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AgentHarness Hooks
|
||||
|
||||
### 1. System Prompt Hooks
|
||||
|
||||
#### `before_agent_start`
|
||||
|
||||
**Location**: `AgentHarness.on("before_agent_start")`
|
||||
|
||||
**Event**:
|
||||
```typescript
|
||||
{
|
||||
type: "before_agent_start";
|
||||
prompt: string;
|
||||
images?: ImageContent[];
|
||||
systemPrompt: string;
|
||||
resources: AgentHarnessResources;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
{
|
||||
messages?: AgentMessage[];
|
||||
systemPrompt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: Before agent starts, after system prompt generated.
|
||||
|
||||
**Use cases**:
|
||||
- Add conversation hints
|
||||
- Inject images
|
||||
- Modify system prompt
|
||||
|
||||
### 2. Context Hooks
|
||||
|
||||
#### `context`
|
||||
|
||||
**Location**: `AgentHarness.on("context")`
|
||||
|
||||
**Event**:
|
||||
```typescript
|
||||
{
|
||||
type: "context";
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
{
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: Before `convertToLlm`.
|
||||
|
||||
**Use cases**:
|
||||
- Message filtering
|
||||
- Context window management
|
||||
- Message augmentation
|
||||
|
||||
### 3. Tool Hooks
|
||||
|
||||
#### `tool_call`
|
||||
|
||||
**Location**: `AgentHarness.on("tool_call")`
|
||||
|
||||
**Event**:
|
||||
```typescript
|
||||
{
|
||||
type: "tool_call";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
{
|
||||
block?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: Before tool execution.
|
||||
|
||||
**Use cases**:
|
||||
- Audit logging
|
||||
- Approval workflows
|
||||
- Input validation
|
||||
|
||||
#### `tool_result`
|
||||
|
||||
**Location**: `AgentHarness.on("tool_result")`
|
||||
|
||||
**Event**:
|
||||
```typescript
|
||||
{
|
||||
type: "tool_result";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
content: (TextContent | ImageContent)[];
|
||||
details: unknown;
|
||||
isError: boolean;
|
||||
usage?: Usage;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
{
|
||||
content?: (TextContent | ImageContent)[];
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
usage?: Usage;
|
||||
terminate?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: After tool execution.
|
||||
|
||||
**Use cases**:
|
||||
- Result transformation
|
||||
- Usage tracking
|
||||
- Early termination
|
||||
|
||||
### 4. Session Hooks
|
||||
|
||||
#### `session_before_compact`
|
||||
|
||||
**Location**: `AgentHarness.on("session_before_compact")`
|
||||
|
||||
**Event**:
|
||||
```typescript
|
||||
{
|
||||
type: "session_before_compact";
|
||||
preparation: BranchPreparation;
|
||||
branchEntries: SessionTreeEntry[];
|
||||
customInstructions?: string;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
{
|
||||
cancel?: boolean;
|
||||
compaction?: CompactionResult;
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: Before compaction.
|
||||
|
||||
**Use cases**:
|
||||
- Skip compaction in certain conditions
|
||||
- Provide custom summary
|
||||
- Abort compaction
|
||||
|
||||
#### `session_before_tree`
|
||||
|
||||
**Location**: `AgentHarness.on("session_before_tree")`
|
||||
|
||||
**Event**:
|
||||
```typescript
|
||||
{
|
||||
type: "session_before_tree";
|
||||
preparation: {
|
||||
targetId: string;
|
||||
oldLeafId: string;
|
||||
commonAncestorId: string;
|
||||
entriesToSummarize: SessionTreeEntry[];
|
||||
userWantsSummary: boolean;
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
signal: AbortSignal;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
{
|
||||
cancel?: boolean;
|
||||
summary?: {
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
usage?: Usage;
|
||||
};
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: Before tree navigation (branching).
|
||||
|
||||
**Use cases**:
|
||||
- Skip branch summary
|
||||
- Provide custom summary
|
||||
- Cancel navigation
|
||||
|
||||
### 5. Provider Hooks
|
||||
|
||||
#### `before_provider_request`
|
||||
|
||||
**Location**: `AgentHarness.on("before_provider_request")`
|
||||
|
||||
**Event**:
|
||||
```typescript
|
||||
{
|
||||
type: "before_provider_request";
|
||||
model: Model<any>;
|
||||
sessionId: string;
|
||||
streamOptions: AgentHarnessStreamOptions;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
{
|
||||
streamOptions: AgentHarnessStreamOptionsPatch;
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: Just before each LLM request.
|
||||
|
||||
**Use cases**:
|
||||
- Add authentication headers
|
||||
- Set request metadata
|
||||
- Configure caching
|
||||
|
||||
#### `before_provider_payload`
|
||||
|
||||
**Location**: `AgentHarness.on("before_provider_payload")`
|
||||
|
||||
**Event**:
|
||||
```typescript
|
||||
{
|
||||
type: "before_provider_payload";
|
||||
model: Model<any>;
|
||||
payload: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Return**:
|
||||
```typescript
|
||||
{
|
||||
payload: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**When called**: Just before sending payload to LLM.
|
||||
|
||||
**Use cases**:
|
||||
- Payload transformation
|
||||
- Debug logging
|
||||
- Schema validation
|
||||
|
||||
---
|
||||
|
||||
## Hook Execution Order
|
||||
|
||||
### Full Turn Flow
|
||||
|
||||
```
|
||||
1. AgentHarness.prompt()
|
||||
│
|
||||
├─► emit "before_agent_start"
|
||||
│ └─► Hook can return new messages/systemPrompt
|
||||
│
|
||||
▼
|
||||
2. AgentLoopConfig creation
|
||||
│
|
||||
├─► transformContext hook → AgentLoop.transformContext
|
||||
├─► convertToLlm hook → AgentLoop.convertToLlm
|
||||
├─► beforeToolCall hook → AgentLoop.beforeToolCall
|
||||
├─► afterToolCall hook → AgentLoop.afterToolCall
|
||||
├─► prepareNextTurn hook → AgentLoop.prepareNextTurn
|
||||
├─► shouldStopAfterTurn hook → AgentLoop.shouldStopAfterTurn
|
||||
├─► getSteeringMessages hook → AgentLoop.getSteeringMessages
|
||||
└─► getFollowUpMessages hook → AgentLoop.getFollowUpMessages
|
||||
│
|
||||
▼
|
||||
3. streamAssistantResponse()
|
||||
│
|
||||
├─► emit "before_provider_request" (harness)
|
||||
│ └─► Hook can modify stream options
|
||||
├─► transformContext() (agent)
|
||||
├─► convertToLlm() (agent)
|
||||
├─► streamFn() → LLM call
|
||||
└─► Emit message_start/update/end events
|
||||
│
|
||||
▼
|
||||
4. executeToolCalls()
|
||||
│
|
||||
├─► For each tool call:
|
||||
│ ├─► emit "tool_call" (harness)
|
||||
│ │ └─► Hook can block execution
|
||||
│ ├─► tool.execute()
|
||||
│ └─► emit "tool_result" (harness)
|
||||
│ └─► Hook can override result
|
||||
│
|
||||
▼
|
||||
5. turn_end
|
||||
│
|
||||
├─► emit "turn_end" (agent)
|
||||
├─► shouldStopAfterTurn() (agent)
|
||||
│ └─► Return true to exit
|
||||
├─► prepareNextTurn() (agent)
|
||||
│ └─► Hook can update context/model/thinkingLevel
|
||||
├─► Drain steering queue
|
||||
└─► Drain follow-up queue
|
||||
│
|
||||
├─► If steering/follow-up: repeat from #3
|
||||
└─► If no more: agent_end
|
||||
│
|
||||
└─► emit "agent_end" (agent)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Queue Mode Behavior
|
||||
|
||||
### `"all"` Mode
|
||||
|
||||
All queued messages are injected at once:
|
||||
|
||||
```
|
||||
Agent would continue...
|
||||
→ getFollowUpMessages returns [msg1, msg2, msg3]
|
||||
→ All three injected together
|
||||
→ Agent processes all before next turn
|
||||
```
|
||||
|
||||
### `"one-at-a-time"` Mode
|
||||
|
||||
One message injected at a time:
|
||||
|
||||
```
|
||||
Agent would continue...
|
||||
→ getFollowUpMessages returns [msg1]
|
||||
→ msg1 injected
|
||||
→ Agent processes msg1
|
||||
→ After turn, getFollowUpMessages returns [msg2]
|
||||
→ msg2 injected
|
||||
→ Agent processes msg2
|
||||
→ ...and so on
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Abort Signal Propagation
|
||||
|
||||
All hooks receive an optional `AbortSignal`:
|
||||
|
||||
```typescript
|
||||
interface BeforeToolCallContext {
|
||||
// ... other fields
|
||||
// signal is NOT included - use agent.signal instead
|
||||
}
|
||||
```
|
||||
|
||||
**Agent hooks**:
|
||||
- `transformContext`: receives `signal`
|
||||
- `beforeToolCall`: receives `signal`
|
||||
- `afterToolCall`: receives `signal`
|
||||
|
||||
**Harness hooks**:
|
||||
- `before_agent_start`: receives `signal`
|
||||
- `context`: NO signal
|
||||
- `tool_call`: NO signal
|
||||
- `tool_result`: NO signal
|
||||
- `session_before_compact`: receives `signal`
|
||||
- `session_before_tree`: receives `signal`
|
||||
- `before_provider_request`: receives `signal`
|
||||
- `before_provider_payload`: NO signal
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Hook Errors
|
||||
|
||||
**Agent layer**: Hook errors are caught and encoded in tool results:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const beforeResult = await config.beforeToolCall(...);
|
||||
if (beforeResult?.block) {
|
||||
return immediateError(beforeResult.reason);
|
||||
}
|
||||
} catch (error) {
|
||||
return immediateError(error.message);
|
||||
}
|
||||
```
|
||||
|
||||
**Harness layer**: Hook errors are wrapped and re-thrown:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await handler(event);
|
||||
} catch (error) {
|
||||
throw normalizeHookError(error);
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Always handle errors**: Wrap async operations in try/catch
|
||||
2. **Respect abort signals**: Check `signal.aborted` in long operations
|
||||
3. **Return safe defaults**: Return empty arrays/objects on errors
|
||||
4. **Don't block**: Hooks should be fast (no network calls)
|
||||
5. **Idempotent**: Hooks should be safe to run multiple times
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Context Window Management
|
||||
|
||||
```typescript
|
||||
transformContext: async (messages, signal) => {
|
||||
if (signal?.aborted) return messages;
|
||||
|
||||
const tokenCount = estimateTokens(messages);
|
||||
if (tokenCount > MAX_TOKENS * 0.9) {
|
||||
return pruneOldestMessages(messages, Math.floor(MAX_TOKENS * 0.3));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Permission-Gated Tools
|
||||
|
||||
```typescript
|
||||
beforeToolCall: async ({ toolCall, args }, signal) => {
|
||||
if (toolCall.name === "bash" && signal?.aborted) {
|
||||
return { block: true, reason: "Operation aborted" };
|
||||
}
|
||||
|
||||
if (toolCall.name === "bash" && !await canExecuteBash(args)) {
|
||||
return { block: true, reason: "Permission denied" };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Result Redaction
|
||||
|
||||
```typescript
|
||||
afterToolCall: async ({ result }) => {
|
||||
const content = result.content.map(c => {
|
||||
if (c.type === "text") {
|
||||
return { ...c, text: redactSecrets(c.text) };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
return { content };
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Early Termination
|
||||
|
||||
```typescript
|
||||
shouldStopAfterTurn: async ({ message }) => {
|
||||
// Check if model indicates completion
|
||||
if (message.content.some(c =>
|
||||
c.type === "text" && c.text.includes("TASK_COMPLETE"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if all tool calls set terminate
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Audit Logging
|
||||
|
||||
```typescript
|
||||
tool_call: async ({ toolCallId, toolName, input }) => {
|
||||
console.log(`[TOOL_CALL] ${toolName} (${toolCallId}):`, input);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
tool_result: async ({ toolCallId, toolName, content, isError }) => {
|
||||
console.log(`[TOOL_RESULT] ${toolName} (${toolCallId}):`, {
|
||||
hasError: isError,
|
||||
contentLength: content.length
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Hook | Layer | When | Can Block? |
|
||||
|------|-------|------|------------|
|
||||
| `convertToLlm` | Agent | Before LLM call | No (sync) |
|
||||
| `transformContext` | Agent | Before `convertToLlm` | Yes (async) |
|
||||
| `beforeToolCall` | Agent | After validation | Yes (async) |
|
||||
| `afterToolCall` | Agent | After execution | Yes (async) |
|
||||
| `shouldStopAfterTurn` | Agent | After turn_end | Yes (async) |
|
||||
| `prepareNextTurn` | Agent | Before next turn | Yes (async) |
|
||||
| `getSteeringMessages` | Agent | After turn_end | Yes (async) |
|
||||
| `getFollowUpMessages` | Agent | When agent would stop | Yes (async) |
|
||||
|
||||
All hooks are **optional** and have sensible defaults.
|
||||
@@ -0,0 +1,705 @@
|
||||
# Session Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The session system provides **persistent, branchable conversation history**. It's the storage layer that enables:
|
||||
|
||||
- Conversation persistence across restarts
|
||||
- Branching to earlier points in conversation
|
||||
- Context window compaction
|
||||
- Session tree navigation
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. SessionTreeEntry
|
||||
|
||||
The fundamental unit of session history:
|
||||
|
||||
```typescript
|
||||
type SessionTreeEntry =
|
||||
| MessageEntry
|
||||
| ModelChangeEntry
|
||||
| ThinkingLevelChangeEntry
|
||||
| ActiveToolsChangeEntry
|
||||
| CompactionEntry
|
||||
| BranchSummaryEntry
|
||||
| CustomEntry
|
||||
| CustomMessageEntry
|
||||
| LabelEntry
|
||||
| LeafEntry
|
||||
| SessionInfoEntry;
|
||||
```
|
||||
|
||||
**Key properties**:
|
||||
- `id`: Unique identifier (UUID v7)
|
||||
- `parentId`: Points to parent entry (forms tree structure)
|
||||
- `timestamp`: ISO 8601 string
|
||||
|
||||
### 2. Tree Structure
|
||||
|
||||
```
|
||||
Entry tree (simplified):
|
||||
|
||||
root (parentId: null)
|
||||
├─► message (user #1) [id: 1]
|
||||
│ └─► message (assistant #1) [id: 2]
|
||||
│ └─► tool_result [id: 3]
|
||||
│ └─► message (user #2) [id: 4]
|
||||
│ └─► compaction [id: 5] ← New root for future
|
||||
│ ├─► retained messages here
|
||||
│ └─► message (assistant #2) [id: 6]
|
||||
│ └─► message (user #3) [id: 7]
|
||||
│ └─► leaf [id: 8] ← Current head
|
||||
│
|
||||
└─► branch_summary [id: 9] ← Point where branch was created
|
||||
└─► message (user #4) [id: 10]
|
||||
└─► message (assistant #4) [id: 11]
|
||||
└─► leaf [id: 12]
|
||||
```
|
||||
|
||||
### 3. Context Building
|
||||
|
||||
**Context** = Current state needed for LLM call:
|
||||
|
||||
```typescript
|
||||
interface SessionContext {
|
||||
systemPrompt: string;
|
||||
messages: AgentMessage[];
|
||||
thinkingLevel: ThinkingLevel;
|
||||
model: { provider: string; modelId: string } | null;
|
||||
activeToolNames: string[] | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Building context** involves:
|
||||
1. Tracing from leaf to root (path entries)
|
||||
2. Applying transforms (compaction, etc.)
|
||||
3. Projecting entries to messages
|
||||
4. Deriving state (model, thinking level, active tools)
|
||||
|
||||
---
|
||||
|
||||
## Session Storage Interface
|
||||
|
||||
### `SessionStorage<TMetadata>`
|
||||
|
||||
```typescript
|
||||
interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
// Metadata
|
||||
readonly id: string;
|
||||
readonly metadata: TMetadata;
|
||||
|
||||
// Entry operations
|
||||
getLeafId(): Promise<string | null>;
|
||||
setLeafId(id: string): Promise<void>;
|
||||
getEntry(id: string): Promise<SessionTreeEntry | undefined>;
|
||||
getEntries(options?: SessionEntryCursorOptions): Promise<SessionTreeEntry[]>;
|
||||
getBranch(): Promise<SessionTreeEntry[]>;
|
||||
|
||||
// Write operations
|
||||
appendEntry(entry: SessionTreeEntry): Promise<string>;
|
||||
|
||||
// Branch operations
|
||||
fork(targetId: string): Promise<SessionStorage>;
|
||||
delete(): Promise<void>;
|
||||
|
||||
// Cleanup
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Built-in Implementations
|
||||
|
||||
#### MemoryStorage
|
||||
|
||||
```typescript
|
||||
class MemoryStorage<TMetadata> implements SessionStorage<TMetadata> {
|
||||
// In-memory storage using Map
|
||||
// Good for: Testing, short-lived sessions
|
||||
// Not good for: Persistence across runs
|
||||
}
|
||||
```
|
||||
|
||||
#### JSONLStorage
|
||||
|
||||
```typescript
|
||||
class JSONLStorage<TMetadata> implements SessionStorage<TMetadata> {
|
||||
// File-based storage using JSONL format
|
||||
// One file per entry: entries/{id}.json
|
||||
// Metadata file: metadata.json
|
||||
|
||||
// Good for: Development, local sessions
|
||||
// Not good for: High-concurrency, production
|
||||
|
||||
// File structure:
|
||||
// session/
|
||||
// metadata.json
|
||||
// entries/
|
||||
// {id1}.json
|
||||
// {id2}.json
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Class
|
||||
|
||||
### `Session<TMetadata>`
|
||||
|
||||
High-level session API built on storage:
|
||||
|
||||
```typescript
|
||||
class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
// Metadata
|
||||
readonly id: string;
|
||||
readonly storage: SessionStorage<TMetadata>;
|
||||
|
||||
// Read operations
|
||||
getMetadata(): Promise<TMetadata>;
|
||||
getLeafId(): Promise<string>;
|
||||
getEntry(id: string): Promise<SessionTreeEntry | undefined>;
|
||||
getBranch(): Promise<SessionTreeEntry[]>;
|
||||
buildContext(options?: SessionContextBuildOptions): Promise<SessionContext>;
|
||||
|
||||
// Write operations
|
||||
appendMessage(message: AgentMessage): Promise<string>;
|
||||
appendModelChange(provider: string, modelId: string): Promise<string>;
|
||||
appendThinkingLevelChange(thinkingLevel: ThinkingLevel): Promise<string>;
|
||||
appendActiveToolsChange(activeToolNames: string[]): Promise<string>;
|
||||
appendCompaction(...): Promise<string>;
|
||||
appendBranchSummary(...): Promise<string>;
|
||||
appendCustomEntry(customType: string, data: unknown): Promise<string>;
|
||||
appendCustomMessageEntry(...): Promise<string>;
|
||||
appendLabel(targetId: string, label: string): Promise<void>;
|
||||
appendSessionName(name: string): Promise<string>;
|
||||
|
||||
// Branch operations
|
||||
fork(targetId: string): Promise<Session>;
|
||||
delete(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Building Details
|
||||
|
||||
### Path Tracing
|
||||
|
||||
**Goal**: Get all entries from leaf to root.
|
||||
|
||||
```typescript
|
||||
async function getPathEntries(session: Session): Promise<SessionTreeEntry[]> {
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let currentId = await session.getLeafId();
|
||||
|
||||
while (currentId !== null) {
|
||||
const entry = await session.getEntry(currentId);
|
||||
if (!entry) break;
|
||||
|
||||
path.unshift(entry);
|
||||
currentId = entry.parentId;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
```
|
||||
|
||||
### Default Transform
|
||||
|
||||
**Purpose**: Apply compaction logic to context.
|
||||
|
||||
```typescript
|
||||
function defaultContextEntryTransform(
|
||||
pathEntries: readonly SessionTreeEntry[]
|
||||
): SessionTreeEntry[] {
|
||||
let compaction: CompactionEntry | null = null;
|
||||
for (const entry of pathEntries) {
|
||||
if (entry.type === "compaction") {
|
||||
compaction = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (!compaction) {
|
||||
return [...pathEntries]; // No compaction
|
||||
}
|
||||
|
||||
// Compaction retains either:
|
||||
// 1. All entries after compaction (retainedTail)
|
||||
// 2. Entries from firstKeptEntryId to compaction (inclusive)
|
||||
|
||||
const entries: SessionTreeEntry[] = [compaction];
|
||||
const compactionIdx = pathEntries.findIndex(e => e.id === compaction.id);
|
||||
|
||||
if (compaction.retainedTail) {
|
||||
// Include everything after compaction
|
||||
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
|
||||
entries.push(pathEntries[i]!);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (compaction.firstKeptEntryId) {
|
||||
// Include entries from firstKeptEntryId to compaction
|
||||
let foundFirstKept = false;
|
||||
for (let i = compactionIdx - 1; i >= 0; i--) {
|
||||
const entry = pathEntries[i]!;
|
||||
if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true;
|
||||
if (foundFirstKept) entries.unshift(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Always include entries after compaction
|
||||
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
|
||||
entries.push(pathEntries[i]!);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
```
|
||||
|
||||
### Entry to Message Projection
|
||||
|
||||
```typescript
|
||||
function sessionEntryToContextMessages(
|
||||
entry: SessionTreeEntry,
|
||||
index: number,
|
||||
entries: readonly SessionTreeEntry[],
|
||||
options: SessionContextBuildOptions = {}
|
||||
): AgentMessage[] {
|
||||
if (entry.type === "message") {
|
||||
return [entry.message as AgentMessage];
|
||||
}
|
||||
|
||||
if (entry.type === "custom_message") {
|
||||
return [createCustomMessage(...)];
|
||||
}
|
||||
|
||||
if (entry.type === "compaction") {
|
||||
return [
|
||||
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
|
||||
...(entry.retainedTail ?? [])
|
||||
];
|
||||
}
|
||||
|
||||
if (entry.type === "branch_summary" && entry.summary) {
|
||||
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
|
||||
}
|
||||
|
||||
if (entry.type === "custom") {
|
||||
// Custom entry projectors can convert to messages
|
||||
return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])];
|
||||
}
|
||||
|
||||
return []; // Skip other entry types
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Branching
|
||||
|
||||
### What is Branching?
|
||||
|
||||
Branching creates a **new session tree** from an existing one, starting at a specific point.
|
||||
|
||||
**Example use case**:
|
||||
```
|
||||
Original tree:
|
||||
root → A → B → C → D (leaf)
|
||||
|
||||
Branch at B:
|
||||
root → A → B → B' (leaf) ← New branch
|
||||
\
|
||||
→ C → D (leaf) ← Original branch
|
||||
```
|
||||
|
||||
### Fork Operation
|
||||
|
||||
```typescript
|
||||
async function fork(session: Session, targetId: string): Promise<Session> {
|
||||
// 1. Clone storage (copy entries up to targetId)
|
||||
const newStorage = await session.storage.fork(targetId);
|
||||
|
||||
// 2. Create new session from storage
|
||||
const newSession = new Session({ storage: newStorage });
|
||||
|
||||
// 3. Set leaf to targetId
|
||||
await newSession.getStorage().setLeafId(targetId);
|
||||
|
||||
return newSession;
|
||||
}
|
||||
```
|
||||
|
||||
### Branch Summary
|
||||
|
||||
When branching, a **branch_summary** entry is created:
|
||||
|
||||
```typescript
|
||||
interface BranchSummaryEntry extends SessionTreeEntryBase {
|
||||
type: "branch_summary";
|
||||
summary: string; // Human-readable summary
|
||||
details?: unknown; // Implementation details
|
||||
usage?: Usage; // LLM usage for generating summary
|
||||
fromId: string; // Entry ID where branch was created
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose**: Help model understand what happened in the branch.
|
||||
|
||||
---
|
||||
|
||||
## Compaction
|
||||
|
||||
### What is Compaction?
|
||||
|
||||
Compaction replaces old conversation history with a **summary**, reducing context size.
|
||||
|
||||
**Before compaction**:
|
||||
```
|
||||
message (user #1)
|
||||
message (assistant #1)
|
||||
tool_result
|
||||
message (user #2)
|
||||
message (assistant #2)
|
||||
tool_result
|
||||
... (many more messages)
|
||||
```
|
||||
|
||||
**After compaction**:
|
||||
```
|
||||
compaction (summary: "User asked X, assistant did Y, then Z...")
|
||||
message (assistant #3) ← Recent messages retained
|
||||
message (user #3)
|
||||
```
|
||||
|
||||
### Compaction Entry
|
||||
|
||||
```typescript
|
||||
interface CompactionEntry extends SessionTreeEntryBase {
|
||||
type: "compaction";
|
||||
summary: string; // Summarized history
|
||||
firstKeptEntryId?: string; // First entry kept after compaction
|
||||
tokensBefore: number; // Context size before compaction
|
||||
details?: CompactionDetails; // File operations, etc.
|
||||
usage?: Usage; // LLM usage for generating summary
|
||||
retainedTail?: AgentMessage[]; // Recent messages stored inline
|
||||
}
|
||||
```
|
||||
|
||||
### Compaction Process
|
||||
|
||||
```typescript
|
||||
async function compact(session: Session): Promise<CompactionResult> {
|
||||
// 1. Get branch entries
|
||||
const entries = await session.getBranch();
|
||||
|
||||
// 2. Prepare compaction
|
||||
const preparation = prepareCompaction(entries, settings);
|
||||
// Identifies which messages to summarize, retained tail, etc.
|
||||
|
||||
// 3. Generate summary using LLM
|
||||
const summary = await generateSummary(
|
||||
preparation.messagesToSummarize,
|
||||
preparation.retainedTail
|
||||
);
|
||||
|
||||
// 4. Create compaction entry
|
||||
const compactionEntry: CompactionEntry = {
|
||||
type: "compaction",
|
||||
id: uuidv7(),
|
||||
parentId: preparation.firstKeptEntry.parentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: summary.text,
|
||||
firstKeptEntryId: preparation.firstKeptEntry.id,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: {
|
||||
readFiles: preparation.fileOps.readFiles,
|
||||
modifiedFiles: preparation.fileOps.modifiedFiles
|
||||
},
|
||||
usage: summary.usage
|
||||
};
|
||||
|
||||
// 5. Persist entry
|
||||
const compactionId = await session.storage.appendEntry(compactionEntry);
|
||||
|
||||
return {
|
||||
summary: summary.text,
|
||||
firstKeptEntryId: preparation.firstKeptEntry.id,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
usage: summary.usage,
|
||||
retainedTail: preparation.retainedTail,
|
||||
details: compactionEntry.details
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Repositories
|
||||
|
||||
### `SessionRepo<TMetadata>`
|
||||
|
||||
Repository pattern for session management:
|
||||
|
||||
```typescript
|
||||
interface SessionRepo<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
// CRUD
|
||||
create(options: CreateSessionOptions<TMetadata>): Promise<Session<TMetadata>>;
|
||||
open(id: string): Promise<Session<TMetadata>>;
|
||||
list(): Promise<SessionInfo[]>;
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
// Forking
|
||||
fork(id: string, targetId: string): Promise<Session<TMetadata>>;
|
||||
|
||||
// Cleanup
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Built-in Implementations
|
||||
|
||||
#### MemoryRepo
|
||||
|
||||
```typescript
|
||||
class MemoryRepo<TMetadata> implements SessionRepo<TMetadata> {
|
||||
// In-memory storage using Map<string, Session<TMetadata>>
|
||||
// Good for: Testing, ephemeral sessions
|
||||
}
|
||||
```
|
||||
|
||||
#### JSONLRepo
|
||||
|
||||
```typescript
|
||||
class JSONLRepo<TMetadata> implements SessionRepo<TMetadata> {
|
||||
// File-based storage
|
||||
// Sessions stored in: sessions/{id}/
|
||||
// Good for: Local development
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entry Types Reference
|
||||
|
||||
### MessageEntry
|
||||
|
||||
```typescript
|
||||
interface MessageEntry extends SessionTreeEntryBase {
|
||||
type: "message";
|
||||
message: AgentMessage;
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: Every user/assistant/toolResult message
|
||||
|
||||
### ModelChangeEntry
|
||||
|
||||
```typescript
|
||||
interface ModelChangeEntry extends SessionTreeEntryBase {
|
||||
type: "model_change";
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: When model is changed via `setModel()`
|
||||
|
||||
### ThinkingLevelChangeEntry
|
||||
|
||||
```typescript
|
||||
interface ThinkingLevelChangeEntry extends SessionTreeEntryBase {
|
||||
type: "thinking_level_change";
|
||||
thinkingLevel: ThinkingLevel;
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: When thinking level is changed via `setThinkingLevel()`
|
||||
|
||||
### ActiveToolsChangeEntry
|
||||
|
||||
```typescript
|
||||
interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
|
||||
type: "active_tools_change";
|
||||
activeToolNames: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: When active tools are changed via `setActiveTools()`
|
||||
|
||||
### CompactionEntry
|
||||
|
||||
```typescript
|
||||
interface CompactionEntry extends SessionTreeEntryBase {
|
||||
type: "compaction";
|
||||
summary: string;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore: number;
|
||||
details?: CompactionDetails;
|
||||
usage?: Usage;
|
||||
retainedTail?: AgentMessage[];
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: After compaction
|
||||
|
||||
### BranchSummaryEntry
|
||||
|
||||
```typescript
|
||||
interface BranchSummaryEntry extends SessionTreeEntryBase {
|
||||
type: "branch_summary";
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
usage?: Usage;
|
||||
fromId: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: When creating a branch
|
||||
|
||||
### CustomEntry
|
||||
|
||||
```typescript
|
||||
interface CustomEntry extends SessionTreeEntryBase {
|
||||
type: "custom";
|
||||
customType: string;
|
||||
data: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: Custom application data (not visible to model)
|
||||
|
||||
### CustomMessageEntry
|
||||
|
||||
```typescript
|
||||
interface CustomMessageEntry extends SessionTreeEntryBase {
|
||||
type: "custom_message";
|
||||
customType: string;
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
display: string;
|
||||
details: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: Custom messages that appear in conversation
|
||||
|
||||
### LabelEntry
|
||||
|
||||
```typescript
|
||||
interface LabelEntry extends SessionTreeEntryBase {
|
||||
type: "label";
|
||||
targetId: string; // Entry ID being labeled
|
||||
label: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: User-assigned labels for entries
|
||||
|
||||
### LeafEntry
|
||||
|
||||
```typescript
|
||||
interface LeafEntry extends SessionTreeEntryBase {
|
||||
type: "leaf";
|
||||
targetId: string; // Current leaf entry ID
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: Updates to current session head
|
||||
|
||||
### SessionInfoEntry
|
||||
|
||||
```typescript
|
||||
interface SessionInfoEntry extends SessionTreeEntryBase {
|
||||
type: "session_info";
|
||||
name: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Stored**: Session name/description
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Branching for Experiments
|
||||
|
||||
```typescript
|
||||
// Original branch
|
||||
await harness.prompt("Build a web app");
|
||||
|
||||
// Experiment branch
|
||||
const experimentalSession = await session.fork(leafId);
|
||||
const experimentalHarness = new AgentHarness({
|
||||
...options,
|
||||
session: experimentalSession
|
||||
});
|
||||
|
||||
await experimentalHarness.prompt("Try using React instead");
|
||||
```
|
||||
|
||||
### 2. Compact Regularly
|
||||
|
||||
```typescript
|
||||
// After each turn, check if compaction needed
|
||||
if (estimateTokens(context) > MAX_TOKENS * 0.8) {
|
||||
await harness.compact();
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use Custom Entries for Metadata
|
||||
|
||||
```typescript
|
||||
// Store application state without exposing to model
|
||||
await harness.appendMessage({
|
||||
role: "custom",
|
||||
type: "task_progress",
|
||||
taskId: "abc123",
|
||||
steps: [...]
|
||||
});
|
||||
|
||||
// Custom entry won't appear in model context
|
||||
```
|
||||
|
||||
### 4. Label Important Points
|
||||
|
||||
```typescript
|
||||
// Mark important conversation points
|
||||
await harness.appendLabel(messageId, "IMPORTANT_DECISION");
|
||||
await harness.appendLabel(messageId, "BLOCKER");
|
||||
```
|
||||
|
||||
### 5. Handle Branching Gracefully
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await harness.navigateTree(targetId, { summarize: true });
|
||||
} catch (error) {
|
||||
if (error instanceof AgentHarnessError && error.code === "branch_summary") {
|
||||
// Branch summary failed, navigate without summary
|
||||
await harness.navigateTree(targetId, { summarize: false });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Session architecture provides**:
|
||||
- Persistent conversation history (JSONL storage)
|
||||
- Branchable conversation trees
|
||||
- Context window compaction
|
||||
- Custom metadata and messages
|
||||
|
||||
**Key operations**:
|
||||
- `buildContext()` → Get LLM context from tree
|
||||
- `appendMessage()` → Add message to tree
|
||||
- `fork()` → Create branch at point
|
||||
- `compact()` → Summarize history
|
||||
|
||||
**Storage layers**:
|
||||
- `MemoryStorage` → Testing, ephemeral
|
||||
- `JSONLStorage` → Development, local
|
||||
@@ -0,0 +1,709 @@
|
||||
# Tool Execution Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Tools are how the agent **interacts with the external world**. They can read files, execute commands, make API calls, or perform any action.
|
||||
|
||||
---
|
||||
|
||||
## Tool Definition
|
||||
|
||||
### Basic Structure
|
||||
|
||||
```typescript
|
||||
interface AgentTool<TParameters extends TSchema, TDetails> extends Tool<TParameters> {
|
||||
label: string; // Human-readable name for UI
|
||||
prepareArguments?: (args: unknown) => Static<TParameters>; // Optional arg transformation
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Result
|
||||
|
||||
```typescript
|
||||
interface AgentToolResult<T> {
|
||||
content: (TextContent | ImageContent)[]; // Returned to model
|
||||
details: T; // Arbitrary data for logs/UI
|
||||
usage?: Usage; // Tool-specific usage (not for LLM context)
|
||||
addedToolNames?: string[]; // New tools introduced
|
||||
terminate?: boolean; // Early termination hint
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Execution Flow
|
||||
|
||||
```
|
||||
1. LLM sends tool call
|
||||
└─► AssistantMessage with toolCall content block
|
||||
|
||||
2. prepareToolCall()
|
||||
├─► Find tool by name
|
||||
├─► prepareArguments() [optional]
|
||||
├─► validateToolArguments()
|
||||
└─► beforeToolCall() hook
|
||||
├─► Return {block: true} → Error tool result
|
||||
└─► Continue
|
||||
|
||||
3. executePreparedToolCall()
|
||||
├─► tool.execute() with onUpdate callback
|
||||
└─► onUpdate(partialResult) → Emit tool_execution_update
|
||||
|
||||
4. finalizeExecutedToolCall()
|
||||
└─► afterToolCall() hook
|
||||
└─► Override result fields
|
||||
|
||||
5. Emit events
|
||||
├─► tool_execution_end
|
||||
├─► message_start (toolResult)
|
||||
└─► message_end (toolResult)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Built-in Tools
|
||||
|
||||
### 1. Bash Tool
|
||||
|
||||
**Purpose**: Execute shell commands.
|
||||
|
||||
**Parameters**:
|
||||
```typescript
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**: Command output as text.
|
||||
|
||||
**Options**:
|
||||
- `cwd`: Working directory
|
||||
- `timeout`: Command timeout in seconds
|
||||
- `maxStdoutLines`: Truncate stdout after N lines
|
||||
- `maxStderrLines`: Truncate stderr after N lines
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const bashTool = createBashTool({
|
||||
cwd: "/home/user/project",
|
||||
timeout: 30,
|
||||
maxStdoutLines: 1000,
|
||||
maxStderrLines: 100
|
||||
});
|
||||
|
||||
await bashTool.execute(
|
||||
"run_123",
|
||||
{ command: "ls -la" },
|
||||
undefined,
|
||||
onUpdate
|
||||
);
|
||||
|
||||
// Result:
|
||||
// {
|
||||
// content: [{ type: "text", text: "drwxr-xr-x ... " }],
|
||||
// details: {
|
||||
// command: "ls -la",
|
||||
// cwd: "/home/user/project",
|
||||
// exitCode: 0,
|
||||
// stdout: "...",
|
||||
// stderr: ""
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
### 2. Read Tool
|
||||
|
||||
**Purpose**: Read files (text or binary).
|
||||
|
||||
**Parameters**:
|
||||
```typescript
|
||||
interface ReadToolInput {
|
||||
path: string;
|
||||
startLine?: number; // Optional line range
|
||||
endLine?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**: File contents as text or images (for image files).
|
||||
|
||||
**Options**:
|
||||
- `maxSize`: Maximum file size in bytes
|
||||
- `maxLines`: Maximum lines for text files
|
||||
- `maxTotalSize`: Maximum total bytes for multiple files
|
||||
- `imageProcessor`: Custom image handler
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const readTool = createReadTool({
|
||||
maxSize: 1024 * 1024, // 1MB
|
||||
maxLines: 5000,
|
||||
imageProcessor: async (buffer) => ({
|
||||
type: "text",
|
||||
text: `Image of ${buffer.length} bytes`
|
||||
})
|
||||
});
|
||||
|
||||
await readTool.execute(
|
||||
"read_456",
|
||||
{ path: "src/app.ts", startLine: 1, endLine: 50 },
|
||||
undefined,
|
||||
onUpdate
|
||||
);
|
||||
|
||||
// Result:
|
||||
// {
|
||||
// content: [{ type: "text", text: "import React from 'react';\n..." }],
|
||||
// details: { path: "src/app.ts", linesRead: 50 }
|
||||
// }
|
||||
```
|
||||
|
||||
### 3. Write Tool
|
||||
|
||||
**Purpose**: Write files (create or overwrite).
|
||||
|
||||
**Parameters**:
|
||||
```typescript
|
||||
interface WriteToolInput {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**: Success/failure message.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const writeTool = createWriteTool();
|
||||
|
||||
await writeTool.execute(
|
||||
"write_789",
|
||||
{ path: "src/app.ts", content: "console.log('Hello');" },
|
||||
undefined,
|
||||
onUpdate
|
||||
);
|
||||
|
||||
// Result:
|
||||
// {
|
||||
// content: [{ type: "text", text: "✓ Wrote 25 bytes to src/app.ts" }],
|
||||
// details: { path: "src/app.ts", bytesWritten: 25 }
|
||||
// }
|
||||
```
|
||||
|
||||
### 4. Edit Tool
|
||||
|
||||
**Purpose**: Make precise edits to files using line numbers or search/replace.
|
||||
|
||||
**Parameters**:
|
||||
```typescript
|
||||
interface EditToolInput {
|
||||
path: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**: Success/failure message with diff.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const editTool = createEditTool();
|
||||
|
||||
await editTool.execute(
|
||||
"edit_101",
|
||||
{ path: "src/app.ts", startLine: 5, endLine: 10, content: "const x = 42;" },
|
||||
undefined,
|
||||
onUpdate
|
||||
);
|
||||
|
||||
// Result:
|
||||
// {
|
||||
// content: [{ type: "text", text: "✓ Edited lines 5-10 in src/app.ts" }],
|
||||
// details: {
|
||||
// path: "src/app.ts",
|
||||
// startLine: 5,
|
||||
// endLine: 10,
|
||||
// linesChanged: 6,
|
||||
// diff: "- const x = 1\n+ const x = 42"
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating Custom Tools
|
||||
|
||||
### Basic Custom Tool
|
||||
|
||||
```typescript
|
||||
const weatherTool: AgentTool<TSchema, WeatherDetails> = {
|
||||
name: "get_weather",
|
||||
label: "Get Weather",
|
||||
description: "Get current weather for a city",
|
||||
parameters: Type.Object({
|
||||
city: Type.String({ description: "City name" })
|
||||
}),
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.weather.com/v1/weather?city=${params.city}`,
|
||||
{ signal }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Weather API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Temperature: ${data.temp}°C` }],
|
||||
details: {
|
||||
city: params.city,
|
||||
temp: data.temp,
|
||||
humidity: data.humidity,
|
||||
condition: data.condition
|
||||
},
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw error; // Re-throw abort
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${error.message}` }],
|
||||
details: { error: error.message },
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Tool with Streaming Updates
|
||||
|
||||
```typescript
|
||||
const backupTool: AgentTool<TSchema, BackupDetails> = {
|
||||
name: "backup_database",
|
||||
label: "Backup Database",
|
||||
description: "Create database backup with progress updates",
|
||||
parameters: Type.Object({
|
||||
database: Type.String(),
|
||||
destination: Type.String()
|
||||
}),
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
const totalSize = await getDatabaseSize(params.database);
|
||||
let uploaded = 0;
|
||||
|
||||
const stream = createBackupStream(params.database);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
uploaded += chunk.length;
|
||||
|
||||
// Stream progress updates
|
||||
onUpdate({
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Backup progress: ${(uploaded / totalSize * 100).toFixed(1)}%`
|
||||
}],
|
||||
details: { uploaded, total: totalSize }
|
||||
});
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Backup cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
await uploadToStorage(stream, params.destination);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Backup completed successfully" }],
|
||||
details: {
|
||||
database: params.database,
|
||||
destination: params.destination,
|
||||
size: uploaded,
|
||||
duration: Date.now() - startTime
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Tool with Custom Error Handling
|
||||
|
||||
```typescript
|
||||
const apiTool: AgentTool<TSchema, ApiDetails> = {
|
||||
name: "make_api_call",
|
||||
label: "Make API Call",
|
||||
description: "Make HTTP request to external API",
|
||||
parameters: Type.Object({
|
||||
url: Type.String({ format: "uri" }),
|
||||
method: Type.Optional(Type.String({ enum: ["GET", "POST", "PUT", "DELETE"] })),
|
||||
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
body: Type.Optional(Type.String())
|
||||
}),
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
try {
|
||||
const response = await fetch(params.url, {
|
||||
method: params.method || "GET",
|
||||
headers: params.headers,
|
||||
body: params.body,
|
||||
signal
|
||||
});
|
||||
|
||||
// Handle HTTP errors
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `HTTP ${response.status}: ${response.statusText}\n${errorBody}`
|
||||
}],
|
||||
details: {
|
||||
url: params.url,
|
||||
method: params.method,
|
||||
statusCode: response.status,
|
||||
body: errorBody
|
||||
},
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
let responseText = await response.text();
|
||||
|
||||
// Handle JSON responses
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const jsonData = JSON.parse(responseText);
|
||||
responseText = JSON.stringify(jsonData, null, 2);
|
||||
} catch {
|
||||
// Not valid JSON, use as-is
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: responseText }],
|
||||
details: {
|
||||
url: params.url,
|
||||
method: params.method,
|
||||
statusCode: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries())
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle network errors
|
||||
return {
|
||||
content: [{ type: "text", text: `Network error: ${error.message}` }],
|
||||
details: {
|
||||
url: params.url,
|
||||
error: error.message
|
||||
},
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Configuration
|
||||
|
||||
### Tool Options
|
||||
|
||||
Tools can be configured with options:
|
||||
|
||||
```typescript
|
||||
const bashTool = createBashTool({
|
||||
cwd: "/home/user/project",
|
||||
timeout: 30,
|
||||
maxStdoutLines: 1000,
|
||||
maxStderrLines: 100
|
||||
});
|
||||
|
||||
const readTool = createReadTool({
|
||||
maxSize: 1024 * 1024, // 1MB
|
||||
maxLines: 5000,
|
||||
maxTotalSize: 10 * 1024 * 1024 // 10MB total
|
||||
});
|
||||
```
|
||||
|
||||
### Tool Context
|
||||
|
||||
Tools can receive application context:
|
||||
|
||||
```typescript
|
||||
interface ToolContext {
|
||||
userId: string;
|
||||
environment: "dev" | "staging" | "prod";
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
const tool: AgentHarnessTool<ToolContext> = {
|
||||
name: "deploy_service",
|
||||
label: "Deploy Service",
|
||||
description: "Deploy service to environment",
|
||||
parameters: Type.Object({
|
||||
service: Type.String(),
|
||||
environment: Type.String({ enum: ["dev", "staging", "prod"] })
|
||||
}),
|
||||
execute: async (toolCallId, params, signal, onUpdate, context) => {
|
||||
// Access context
|
||||
if (!context.permissions.includes("deploy")) {
|
||||
throw new Error("Permission denied");
|
||||
}
|
||||
|
||||
if (context.environment === "prod" && !params.environment) {
|
||||
throw new Error("Must specify environment for prod deployment");
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
};
|
||||
|
||||
const harness = new AgentHarness({
|
||||
tools: [tool],
|
||||
toolContext: {
|
||||
userId: "user123",
|
||||
environment: "prod",
|
||||
permissions: ["read", "write", "deploy"]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Execution Modes
|
||||
|
||||
### Sequential Mode
|
||||
|
||||
Tools marked as sequential execute **one at a time**:
|
||||
|
||||
```typescript
|
||||
const sequentialTool: AgentTool<TSchema> = {
|
||||
name: "sequential_tool",
|
||||
label: "Sequential Tool",
|
||||
description: "Must run one at a time",
|
||||
parameters: Type.Object({}),
|
||||
executionMode: "sequential", // Key point
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
// This tool won't run concurrently with other sequential tools
|
||||
// Even if LLM sends multiple tool calls
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Parallel Mode (Default)
|
||||
|
||||
Tools execute **concurrently** by default:
|
||||
|
||||
```typescript
|
||||
const parallelTool: AgentTool<TSchema> = {
|
||||
name: "parallel_tool",
|
||||
label: "Parallel Tool",
|
||||
description: "Can run concurrently",
|
||||
parameters: Type.Object({}),
|
||||
// executionMode defaults to "parallel"
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
// This tool can run alongside other parallel tools
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Agent-Level Execution Mode
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
initialState: {...},
|
||||
streamFn: ...
|
||||
toolExecution: "sequential" // All tools sequential by default
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Tool Errors
|
||||
|
||||
Tools should **throw** on critical errors (abort, timeout) but **return error results** on recoverable errors:
|
||||
|
||||
```typescript
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
try {
|
||||
// Check for abort first
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Operation aborted");
|
||||
}
|
||||
|
||||
// Do work...
|
||||
|
||||
// Return error result for recoverable errors
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: Invalid input" }],
|
||||
details: { error: "Invalid input" },
|
||||
isError: true
|
||||
};
|
||||
} catch (error) {
|
||||
// Re-throw abort errors
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Return error result for other errors
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${error.message}` }],
|
||||
details: { error: error.message },
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Blockable Tools
|
||||
|
||||
Use `beforeToolCall` hook to block tool execution:
|
||||
|
||||
```typescript
|
||||
beforeToolCall: async ({ toolCall, args }, signal) => {
|
||||
if (toolCall.name === "bash") {
|
||||
// Check for dangerous commands
|
||||
const dangerousPatterns = ["rm -rf", "sudo", "dd if="];
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (args.command?.includes(pattern)) {
|
||||
return { block: true, reason: "Dangerous command blocked" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined; // Allow execution
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Respect Abort Signals
|
||||
|
||||
```typescript
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Operation aborted");
|
||||
}
|
||||
|
||||
// Long-running operation
|
||||
for await (const item of longProcess()) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Operation aborted");
|
||||
}
|
||||
onUpdate({ content: [{ type: "text", text: "Processing..." }] });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Return Meaningful Error Messages
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
return { content: [{ type: "text", text: "Error" }], isError: true };
|
||||
|
||||
// Good
|
||||
return {
|
||||
content: [{ type: "text", text: "Failed to read file: permission denied" }],
|
||||
details: { path: "/etc/passwd", error: "EACCES" },
|
||||
isError: true
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Stream Progress for Long Operations
|
||||
|
||||
```typescript
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Do work...
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: `Progress: ${i}%` }],
|
||||
details: { progress: i }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Complete" }],
|
||||
details: { progress: 100 }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Proper Tool Result Types
|
||||
|
||||
```typescript
|
||||
interface BashDetails {
|
||||
command: string;
|
||||
cwd: string;
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Command executed" }],
|
||||
details: { command, cwd, exitCode, stdout, stderr } as BashDetails
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Handle Large Outputs
|
||||
|
||||
```typescript
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
const stdoutLines: string[] = [];
|
||||
const stderrLines: string[] = [];
|
||||
|
||||
for await (const chunk of process.stdout) {
|
||||
stdoutLines.push(chunk);
|
||||
if (stdoutLines.length > MAX_LINES) {
|
||||
break; // Truncate
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: truncate(stdoutLines.join("\n")) }],
|
||||
details: { stdout: stdoutLines.join("\n") }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Tools are the bridge** between the agent and the external world.
|
||||
|
||||
**Key principles**:
|
||||
- Return `isError: true` for recoverable errors
|
||||
- Throw on abort/timeout
|
||||
- Stream progress for long operations
|
||||
- Respect abort signals throughout
|
||||
- Use detailed error messages
|
||||
|
||||
**Built-in tools**:
|
||||
- `bash`: Execute shell commands
|
||||
- `read`: Read files
|
||||
- `write`: Write files
|
||||
- `edit`: Make precise edits
|
||||
|
||||
**Custom tools** can do anything: API calls, database queries, file operations, etc.
|
||||
@@ -0,0 +1,803 @@
|
||||
# AgentHarness Reference
|
||||
|
||||
## Overview
|
||||
|
||||
`AgentHarness` is the **high-level API** that wraps the core agent with session management, persistence, branching, and tool context binding.
|
||||
|
||||
---
|
||||
|
||||
## Key Differences: Agent vs AgentHarness
|
||||
|
||||
| Feature | Agent (Core) | AgentHarness |
|
||||
|---------|-------------|--------------|
|
||||
| **Session Persistence** | No | Yes (JSONL/Memory) |
|
||||
| **Branching** | No | Yes |
|
||||
| **Context Compaction** | No | Yes |
|
||||
| **Tool Context** | Manual | Automatic binding |
|
||||
| **Skills/Templates** | Manual | Built-in |
|
||||
| **State Management** | Manual | Automatic |
|
||||
| **Event Hooks** | Basic | Rich system |
|
||||
|
||||
---
|
||||
|
||||
## AgentHarness Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ AgentHarness │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ State │
|
||||
│ ├─ Session (persistence) │
|
||||
│ ├─ Model │
|
||||
│ ├─ ThinkingLevel │
|
||||
│ ├─ Tools (Map) │
|
||||
│ ├─ ActiveTools (string[]) │
|
||||
│ └─ SystemPrompt (string or function) │
|
||||
│ │
|
||||
│ Queues │
|
||||
│ ├─ steerQueue (messages to interrupt agent) │
|
||||
│ ├─ followUpQueue (messages after agent stops) │
|
||||
│ └─ nextTurnQueue (messages for next turn) │
|
||||
│ │
|
||||
│ Hooks │
|
||||
│ ├─ before_agent_start │
|
||||
│ ├─ context │
|
||||
│ ├─ tool_call │
|
||||
│ ├─ tool_result │
|
||||
│ ├─ session_before_compact │
|
||||
│ ├─ session_before_tree │
|
||||
│ ├─ before_provider_request │
|
||||
│ └─ before_provider_payload │
|
||||
│ │
|
||||
│ Methods │
|
||||
│ ├─ prompt() - Run new conversation │
|
||||
│ ├─ skill() - Execute skill │
|
||||
│ ├─ promptFromTemplate() - Run template │
|
||||
│ ├─ steer() - Interrupt agent │
|
||||
│ ├─ followUp() - Queue message │
|
||||
│ ├─ compact() - Compress context │
|
||||
│ ├─ navigateTree() - Branch session │
|
||||
│ └─ subscribe() - Add event listener │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Session
|
||||
|
||||
The session holds **conversation history as a tree**:
|
||||
|
||||
```typescript
|
||||
interface Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
readonly id: string;
|
||||
readonly storage: SessionStorage<TMetadata>;
|
||||
|
||||
getMetadata(): Promise<TMetadata>;
|
||||
getLeafId(): Promise<string>;
|
||||
getEntry(id: string): Promise<SessionTreeEntry | undefined>;
|
||||
getBranch(): Promise<SessionTreeEntry[]>;
|
||||
buildContext(options?: SessionContextBuildOptions): Promise<SessionContext>;
|
||||
|
||||
appendMessage(message: AgentMessage): Promise<string>;
|
||||
appendModelChange(provider: string, modelId: string): Promise<string>;
|
||||
appendThinkingLevelChange(thinkingLevel: ThinkingLevel): Promise<string>;
|
||||
appendActiveToolsChange(activeToolNames: string[]): Promise<string>;
|
||||
appendCompaction(...): Promise<string>;
|
||||
appendBranchSummary(...): Promise<string>;
|
||||
|
||||
fork(targetId: string): Promise<Session>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Resources
|
||||
|
||||
Skills and prompt templates available to the agent:
|
||||
|
||||
```typescript
|
||||
interface AgentHarnessResources<TSkill = Skill, TPromptTemplate = PromptTemplate> {
|
||||
skills?: TSkill[];
|
||||
promptTemplates?: TPromptTemplate[];
|
||||
}
|
||||
|
||||
interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
filePath: string;
|
||||
disableModelInvocation?: boolean;
|
||||
}
|
||||
|
||||
interface PromptTemplate {
|
||||
name: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Tool Context
|
||||
|
||||
Context passed to all tool executions:
|
||||
|
||||
```typescript
|
||||
interface ToolContext {
|
||||
userId: string;
|
||||
environment: "dev" | "staging" | "prod";
|
||||
// ... custom properties
|
||||
}
|
||||
|
||||
// Zero-arg function for dynamic context
|
||||
type ToolContextProvider<TContext> = () => TContext | Promise<TContext>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AgentHarness API
|
||||
|
||||
### Constructor
|
||||
|
||||
```typescript
|
||||
constructor(options: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>)
|
||||
```
|
||||
|
||||
**Options**:
|
||||
```typescript
|
||||
interface AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool> {
|
||||
session: Session; // Session storage
|
||||
models: Models; // LLM provider
|
||||
resources?: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
streamOptions?: AgentHarnessStreamOptions;
|
||||
retry?: RetryPolicy;
|
||||
|
||||
// System prompt
|
||||
systemPrompt?:
|
||||
| string // Static string
|
||||
| AgentHarnessSystemPrompt<TContext, TSkill, TPromptTemplate, TTool>; // Dynamic function
|
||||
|
||||
// Tool context
|
||||
toolContext?: AgentHarnessToolContextSource<TContext>;
|
||||
|
||||
// Tools
|
||||
tools?: TTool[];
|
||||
|
||||
// Active tools
|
||||
activeToolNames?: string[];
|
||||
|
||||
// Model and thinking
|
||||
model: Model<any>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
|
||||
// Queue modes
|
||||
steeringMode?: QueueMode;
|
||||
followUpMode?: QueueMode;
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const harness = new AgentHarness({
|
||||
session: memorySession,
|
||||
models: models,
|
||||
resources: {
|
||||
skills: [weatherSkill, gitSkill],
|
||||
promptTemplates: [summaryTemplate]
|
||||
},
|
||||
systemPrompt: async ({ session, model, activeTools, resources }) => {
|
||||
const sessionMetadata = await session.getMetadata();
|
||||
const toolsList = activeTools.map(t => t.name).join(", ");
|
||||
|
||||
return `You are an AI assistant with access to tools: ${toolsList}.
|
||||
|
||||
Current session: ${sessionMetadata.id}
|
||||
Date: ${new Date().toISOString()}
|
||||
|
||||
Available skills:
|
||||
${resources.skills?.map(s => `- ${s.name}: ${s.description}`).join("\n")}
|
||||
`;
|
||||
},
|
||||
toolContext: { userId: "user123", environment: "prod" },
|
||||
tools: [weatherTool, gitTool, readFileTool],
|
||||
activeToolNames: ["weather", "git"],
|
||||
model: gpt4Model,
|
||||
thinkingLevel: "medium"
|
||||
});
|
||||
```
|
||||
|
||||
### System Prompt
|
||||
|
||||
**Static string**:
|
||||
```typescript
|
||||
systemPrompt: "You are a helpful assistant."
|
||||
```
|
||||
|
||||
**Dynamic function**:
|
||||
```typescript
|
||||
systemPrompt: async ({
|
||||
session,
|
||||
model,
|
||||
thinkingLevel,
|
||||
activeTools,
|
||||
resources
|
||||
}) => {
|
||||
const metadata = await session.getMetadata();
|
||||
|
||||
return `System: ${metadata.id}
|
||||
Model: ${model.id}
|
||||
Date: ${new Date().toISOString()}
|
||||
|
||||
Active tools: ${activeTools.map(t => t.name).join(", ")}
|
||||
`;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Main Methods
|
||||
|
||||
### `prompt()`
|
||||
|
||||
Run a new prompt:
|
||||
|
||||
```typescript
|
||||
async prompt(text: string, options?: { images?: ImageContent[] }): Promise<AssistantMessage>
|
||||
```
|
||||
|
||||
**Flow**:
|
||||
1. Validate harness is idle
|
||||
2. Create turn state (context, tools, system prompt)
|
||||
3. Emit `before_agent_start` hook
|
||||
4. Run agent loop with prompt
|
||||
5. Return assistant message
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const message = await harness.prompt("What's the weather in London?");
|
||||
console.log(message.content); // Assistant response
|
||||
```
|
||||
|
||||
### `skill()`
|
||||
|
||||
Execute a named skill:
|
||||
|
||||
```typescript
|
||||
async skill(name: string, additionalInstructions?: string): Promise<AssistantMessage>
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const message = await harness.skill("git", "Also create a PR for the changes");
|
||||
// Skill content injected into prompt
|
||||
```
|
||||
|
||||
### `promptFromTemplate()`
|
||||
|
||||
Execute a prompt template:
|
||||
|
||||
```typescript
|
||||
async promptFromTemplate(
|
||||
name: string,
|
||||
args: string[] = []
|
||||
): Promise<AssistantMessage>
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// Template: "Fix the following error: {{0}}"
|
||||
const message = await harness.promptFromTemplate("fix_error", ["TypeError: x is undefined"]);
|
||||
```
|
||||
|
||||
### `steer()`
|
||||
|
||||
Interrupt agent mid-execution:
|
||||
|
||||
```typescript
|
||||
async steer(text: string, options?: { images?: ImageContent[] }): Promise<void>
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
await harness.prompt("Write a long report...");
|
||||
// While agent is working...
|
||||
await harness.steer("Wait, change focus to climate change");
|
||||
// Agent continues with new instructions
|
||||
```
|
||||
|
||||
### `followUp()`
|
||||
|
||||
Queue message for after agent stops:
|
||||
|
||||
```typescript
|
||||
async followUp(text: string, options?: { images?: ImageContent[] }): Promise<void>
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
await harness.prompt("Analyze this data...");
|
||||
// Agent finishes...
|
||||
await harness.followUp("Now create a summary");
|
||||
// Agent continues with summary request
|
||||
```
|
||||
|
||||
### `nextTurn()`
|
||||
|
||||
Queue message for next turn (doesn't interrupt current turn):
|
||||
|
||||
```typescript
|
||||
async nextTurn(text: string, options?: { images?: ImageContent[] }): Promise<void>
|
||||
```
|
||||
|
||||
**Difference from `steer()`**:
|
||||
- `steer()`: Interrupts immediately
|
||||
- `nextTurn()`: Waits for current turn to finish
|
||||
|
||||
### `compact()`
|
||||
|
||||
Compress conversation history:
|
||||
|
||||
```typescript
|
||||
async compact(customInstructions?: string): Promise<CompactResult>
|
||||
```
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
interface CompactResult {
|
||||
summary: string;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore: number;
|
||||
usage?: Usage;
|
||||
retainedTail?: AgentMessage[];
|
||||
details?: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const result = await harness.compact();
|
||||
console.log(`Compressed from ${result.tokensBefore} tokens to summary`);
|
||||
```
|
||||
|
||||
### `navigateTree()`
|
||||
|
||||
Navigate conversation tree (branching):
|
||||
|
||||
```typescript
|
||||
async navigateTree(
|
||||
targetId: string,
|
||||
options?: {
|
||||
summarize?: boolean;
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
): Promise<NavigateTreeResult>
|
||||
```
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
interface NavigateTreeResult {
|
||||
cancelled: boolean;
|
||||
editorText?: string; // If target is user message
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// Navigate to earlier point in conversation
|
||||
const result = await harness.navigateTree("entry_abc123", { summarize: true });
|
||||
|
||||
// Create branch from current point
|
||||
const newHarness = createNewHarness();
|
||||
await newHarness.navigateTree("entry_xyz789");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### Model
|
||||
|
||||
```typescript
|
||||
getModel(): Model<any>;
|
||||
|
||||
async setModel(model: Model<any>): Promise<void>;
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
console.log(harness.getModel().id); // "gpt-4"
|
||||
|
||||
await harness.setModel(gpt4oModel);
|
||||
```
|
||||
|
||||
### Thinking Level
|
||||
|
||||
```typescript
|
||||
getThinkingLevel(): ThinkingLevel;
|
||||
|
||||
async setThinkingLevel(level: ThinkingLevel): Promise<void>;
|
||||
```
|
||||
|
||||
**Levels**: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
await harness.setThinkingLevel("high"); // More reasoning for complex tasks
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
```typescript
|
||||
getTools(): TTool[];
|
||||
getActiveTools(): TTool[];
|
||||
|
||||
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void>;
|
||||
async setActiveTools(toolNames: string[]): Promise<void>;
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// Add new tool
|
||||
await harness.setTools([...harness.getTools(), newTool]);
|
||||
|
||||
// Change active tools
|
||||
await harness.setActiveTools(["read", "write"]);
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
```typescript
|
||||
getResources(): AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
|
||||
async setResources(resources: AgentHarnessResources<TSkill, TPromptTemplate>): Promise<void>;
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
await harness.setResources({
|
||||
skills: [...harness.getResources().skills, newSkill]
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Queue Management
|
||||
|
||||
### Steering Queue
|
||||
|
||||
```typescript
|
||||
getSteeringMode(): QueueMode;
|
||||
|
||||
async setSteeringMode(mode: QueueMode): Promise<void>;
|
||||
```
|
||||
|
||||
**Modes**:
|
||||
- `"all"`: Drain all queued messages at once
|
||||
- `"one-at-a-time"`: Drain one message at a time
|
||||
|
||||
### Follow-up Queue
|
||||
|
||||
```typescript
|
||||
getFollowUpMode(): QueueMode;
|
||||
|
||||
async setFollowUpMode(mode: QueueMode): Promise<void>;
|
||||
```
|
||||
|
||||
### Queue Helpers
|
||||
|
||||
```typescript
|
||||
// Clear all queued messages
|
||||
harness.clearAllQueues();
|
||||
|
||||
// Check if queues have pending messages
|
||||
harness.hasQueuedMessages(); // boolean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Handling
|
||||
|
||||
### Subscribe to All Events
|
||||
|
||||
```typescript
|
||||
subscribe(
|
||||
listener: (event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal) => Promise<void> | void
|
||||
): () => void;
|
||||
```
|
||||
|
||||
**Event types**:
|
||||
```typescript
|
||||
type AgentHarnessEvent<TSkill, TPromptTemplate> =
|
||||
// Agent events (forwarded from core agent)
|
||||
| { type: "agent_start" }
|
||||
| { type: "agent_end"; messages: AgentMessage[] }
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
|
||||
| { type: "message_start"; message: AgentMessage }
|
||||
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||
| { type: "message_end"; message: AgentMessage }
|
||||
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
|
||||
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
|
||||
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean }
|
||||
|
||||
// Harness-specific events
|
||||
| { type: "before_agent_start"; ... }
|
||||
| { type: "context"; messages: AgentMessage[] }
|
||||
| { type: "tool_call"; ... }
|
||||
| { type: "tool_result"; ... }
|
||||
| { type: "session_before_compact"; ... }
|
||||
| { type: "session_before_tree"; ... }
|
||||
| { type: "before_provider_request"; ... }
|
||||
| { type: "before_provider_payload"; ... }
|
||||
| { type: "after_provider_response"; ... }
|
||||
| { type: "save_point"; ... }
|
||||
| { type: "settled"; ... }
|
||||
| { type: "model_update"; ... }
|
||||
| { type: "thinking_level_update"; ... }
|
||||
| { type: "tools_update"; ... }
|
||||
| { type: "resources_update"; ... }
|
||||
| { type: "session_compact"; ... }
|
||||
| { type: "session_tree"; ... }
|
||||
| { type: "queue_update"; ... }
|
||||
| { type: "retry_scheduled"; ... }
|
||||
| { type: "retry_attempt_start"; ... }
|
||||
| { type: "retry_finished"; ... }
|
||||
| { type: "abort"; clearedSteer: UserMessage[]; clearedFollowUp: UserMessage[] };
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const unsubscribe = harness.subscribe(async (event, signal) => {
|
||||
if (event.type === "message_end") {
|
||||
console.log("Message:", event.message.role);
|
||||
}
|
||||
|
||||
if (event.type === "agent_end") {
|
||||
console.log("Conversation complete");
|
||||
}
|
||||
|
||||
if (event.type === "tool_execution_end") {
|
||||
console.log("Tool:", event.toolName, "completed");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Subscribe to Specific Events
|
||||
|
||||
```typescript
|
||||
on<TType extends keyof AgentHarnessEventResultMap>(
|
||||
type: TType,
|
||||
handler: (event: Extract<AgentHarnessOwnEvent, { type: TType }>) => Promise<AgentHarnessEventResultMap[TType]> | AgentHarnessEventResultMap[TType]
|
||||
): () => void;
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// Handle tool calls
|
||||
harness.on("tool_call", async ({ toolCallId, toolName, input }) => {
|
||||
console.log(`Tool ${toolName} called with:`, input);
|
||||
return undefined; // Allow execution
|
||||
});
|
||||
|
||||
// Handle tool results
|
||||
harness.on("tool_result", async ({ toolName, content, isError }) => {
|
||||
console.log(`Tool ${toolName} result:`, isError ? "Error" : "Success");
|
||||
return undefined; // Use default result
|
||||
});
|
||||
|
||||
// Modify system prompt
|
||||
harness.on("before_agent_start", async ({ systemPrompt }) => {
|
||||
return {
|
||||
systemPrompt: `${systemPrompt}\n\nRemember to be concise.`
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Persistence
|
||||
|
||||
### Append Message
|
||||
|
||||
```typescript
|
||||
async appendMessage(message: AgentMessage): Promise<void>;
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// Manually add message to session
|
||||
await harness.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Custom message" }],
|
||||
timestamp: Date.now()
|
||||
});
|
||||
```
|
||||
|
||||
### Flush Pending Writes
|
||||
|
||||
```typescript
|
||||
async abort(): Promise<AbortResult>
|
||||
```
|
||||
|
||||
**Returns**:
|
||||
```typescript
|
||||
interface AbortResult {
|
||||
clearedSteer: UserMessage[];
|
||||
clearedFollowUp: UserMessage[];
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const result = await harness.abort();
|
||||
console.log(`Cleared ${result.clearedSteer.length} steering messages`);
|
||||
```
|
||||
|
||||
### Wait for Idle
|
||||
|
||||
```typescript
|
||||
async waitForIdle(): Promise<void>;
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
await harness.prompt("Do something...");
|
||||
await harness.waitForIdle(); // Wait for completion
|
||||
console.log("Done");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Codes
|
||||
|
||||
```typescript
|
||||
type AgentHarnessErrorCode =
|
||||
| "busy" // Agent is already processing
|
||||
| "invalid_state" // Invalid state for operation
|
||||
| "invalid_argument" // Invalid arguments
|
||||
| "session" // Session error
|
||||
| "hook" // Hook error
|
||||
| "auth" // Authentication error
|
||||
| "compaction" // Compaction error
|
||||
| "branch_summary" // Branch summary error
|
||||
| "unknown"; // Unknown error
|
||||
```
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await harness.prompt("Do something");
|
||||
} catch (error) {
|
||||
if (error instanceof AgentHarnessError) {
|
||||
switch (error.code) {
|
||||
case "busy":
|
||||
console.log("Agent busy, try again later");
|
||||
break;
|
||||
case "compaction":
|
||||
console.log("Compaction failed:", error.message);
|
||||
break;
|
||||
case "hook":
|
||||
console.log("Hook error:", error.cause?.message);
|
||||
break;
|
||||
default:
|
||||
console.log("Error:", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### 1. Dynamic System Prompt
|
||||
|
||||
```typescript
|
||||
systemPrompt: async ({ session, model, activeTools, resources }) => {
|
||||
const metadata = await session.getMetadata();
|
||||
|
||||
// Customize based on session type
|
||||
if (metadata.type === "coding") {
|
||||
return `You are a coding assistant. Use tools: ${activeTools.map(t => t.name).join(", ")}`;
|
||||
} else if (metadata.type === "writing") {
|
||||
return `You are a writing assistant. Focus on clarity and style.`;
|
||||
}
|
||||
|
||||
return "You are a helpful assistant.";
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Conditional Tool Activation
|
||||
|
||||
```typescript
|
||||
// Enable tools based on user request
|
||||
harness.on("before_agent_start", async ({ prompt }) => {
|
||||
if (prompt.includes("weather")) {
|
||||
return {
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Enable weather tool" }] }]
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Session Branching
|
||||
|
||||
```typescript
|
||||
async function exploreAlternative(harness: AgentHarness, prompt: string): Promise<AssistantMessage> {
|
||||
// Get current leaf
|
||||
const leafId = await harness.session.getLeafId();
|
||||
|
||||
// Create branch
|
||||
const branchSession = await harness.session.fork(leafId);
|
||||
const branchHarness = new AgentHarness({
|
||||
...harnessOptions,
|
||||
session: branchSession
|
||||
});
|
||||
|
||||
// Run alternative
|
||||
return await branchHarness.prompt(prompt);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Custom Compaction
|
||||
|
||||
```typescript
|
||||
harness.on("session_before_compact", async ({ preparation }) => {
|
||||
// Skip compaction for short sessions
|
||||
if (preparation.tokensBefore < 1000) {
|
||||
return { cancel: true };
|
||||
}
|
||||
|
||||
// Provide custom summary
|
||||
return {
|
||||
compaction: {
|
||||
summary: "User asked about X, Y, Z and assistant provided guidance.",
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
firstKeptEntryId: preparation.firstKeptEntry.id,
|
||||
details: { manual: true }
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Tool Execution Logging
|
||||
|
||||
```typescript
|
||||
harness.on("tool_call", async ({ toolName, input }) => {
|
||||
console.log(`[TOOL_CALL] ${toolName}:`, JSON.stringify(input, null, 2));
|
||||
return undefined;
|
||||
});
|
||||
|
||||
harness.on("tool_result", async ({ toolName, content, isError }) => {
|
||||
console.log(`[TOOL_RESULT] ${toolName}:`, isError ? "❌" : "✅");
|
||||
return undefined;
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**AgentHarness provides**:
|
||||
- Session persistence and tree navigation
|
||||
- Built-in tool context binding
|
||||
- Rich hook system for customization
|
||||
- Skills and prompt templates
|
||||
- Context compaction and branching
|
||||
|
||||
**Key methods**:
|
||||
- `prompt()` - Main interaction
|
||||
- `steer()` / `followUp()` - Queue management
|
||||
- `compact()` - Context management
|
||||
- `navigateTree()` - Branching
|
||||
|
||||
**Key patterns**:
|
||||
- Dynamic system prompts
|
||||
- Conditional tool activation
|
||||
- Session branching for experimentation
|
||||
- Hook-based customization
|
||||
@@ -0,0 +1,687 @@
|
||||
# Data Flow and State Management
|
||||
|
||||
## Overview
|
||||
|
||||
Understanding how data flows through the agent system is crucial for debugging and extending functionality.
|
||||
|
||||
---
|
||||
|
||||
## Message Flow
|
||||
|
||||
### 1. Input Messages
|
||||
|
||||
```typescript
|
||||
// User input
|
||||
await harness.prompt("Build a web app");
|
||||
|
||||
// Internal messages
|
||||
await harness.steer("Wait, use React");
|
||||
await harness.followUp("Now add tests");
|
||||
await harness.nextTurn("Also deploy to production");
|
||||
```
|
||||
|
||||
**Normalization**:
|
||||
```typescript
|
||||
function normalizePromptInput(input: string | AgentMessage | AgentMessage[]): AgentMessage[] {
|
||||
if (Array.isArray(input)) return input;
|
||||
|
||||
if (typeof input !== "string") {
|
||||
return [input]; // Already a message
|
||||
}
|
||||
|
||||
// String → user message
|
||||
return [{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: input }],
|
||||
timestamp: Date.now()
|
||||
}];
|
||||
}
|
||||
```
|
||||
|
||||
### 2. AgentMessage Types
|
||||
|
||||
```typescript
|
||||
type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant" | "toolResult";
|
||||
content: (TextContent | ImageContent)[];
|
||||
api?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
usage?: Usage;
|
||||
stopReason?: StopReason;
|
||||
errorMessage?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ImageContent {
|
||||
type: "image";
|
||||
mediaType: string;
|
||||
data: string; // Base64
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Message Lifecycle
|
||||
|
||||
```
|
||||
User Input
|
||||
│
|
||||
▼
|
||||
normalizePromptInput() → AgentMessage[]
|
||||
│
|
||||
▼
|
||||
runPromptMessages() → runWithLifecycle()
|
||||
│
|
||||
├─► Set isStreaming=true
|
||||
├─► Create abort controller
|
||||
└─► runAgentLoop()
|
||||
│
|
||||
▼
|
||||
runLoop()
|
||||
│
|
||||
├─► message_start (user prompt)
|
||||
├─► message_end
|
||||
├─► streamAssistantResponse()
|
||||
│ ├─► message_start (assistant)
|
||||
│ ├─► message_update (chunks)
|
||||
│ └─► message_end
|
||||
├─► executeToolCalls()
|
||||
│ └─► message_start/end (toolResults)
|
||||
└─► turn_end
|
||||
│
|
||||
▼
|
||||
handleAgentEvent() (harness)
|
||||
│
|
||||
├─► session.appendMessage()
|
||||
│ └─► Storage: write entry
|
||||
└─► Emit: message_end (forwarded)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### Agent State
|
||||
|
||||
```typescript
|
||||
interface AgentState {
|
||||
systemPrompt: string;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: AgentTool<any>[];
|
||||
messages: AgentMessage[];
|
||||
isStreaming: boolean;
|
||||
streamingMessage?: AgentMessage;
|
||||
pendingToolCalls: Set<string>;
|
||||
errorMessage?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**State changes**:
|
||||
|
||||
| Event | State Changed |
|
||||
|-------|--------------|
|
||||
| `message_start` | `streamingMessage` = message |
|
||||
| `message_update` | `streamingMessage` = message |
|
||||
| `message_end` | `messages.push(message)`, `streamingMessage` = undefined |
|
||||
| `tool_execution_start` | `pendingToolCalls.add(toolCallId)` |
|
||||
| `tool_execution_end` | `pendingToolCalls.delete(toolCallId)` |
|
||||
| `turn_end` | `errorMessage` (if error) |
|
||||
| `agent_end` | `streamingMessage` = undefined |
|
||||
|
||||
### State Mutation Example
|
||||
|
||||
```typescript
|
||||
// In Agent.processEvents()
|
||||
private async processEvents(event: AgentEvent): Promise<void> {
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
this._state.streamingMessage = event.message;
|
||||
break;
|
||||
|
||||
case "message_end":
|
||||
this._state.streamingMessage = undefined;
|
||||
this._state.messages.push(event.message);
|
||||
break;
|
||||
|
||||
case "tool_execution_start": {
|
||||
const pending = new Set(this._state.pendingToolCalls);
|
||||
pending.add(event.toolCallId);
|
||||
this._state.pendingToolCalls = pending;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_end": {
|
||||
const pending = new Set(this._state.pendingToolCalls);
|
||||
pending.delete(event.toolCallId);
|
||||
this._state.pendingToolCalls = pending;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit to listeners
|
||||
for (const listener of this.listeners) {
|
||||
await listener(event, signal);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Flow
|
||||
|
||||
### Context Snapshot
|
||||
|
||||
```typescript
|
||||
interface AgentContext {
|
||||
systemPrompt: string;
|
||||
messages: AgentMessage[];
|
||||
tools?: AgentTool<any>[];
|
||||
}
|
||||
```
|
||||
|
||||
**When created**:
|
||||
1. `Agent.createContextSnapshot()` - before each LLM call
|
||||
2. `AgentHarness.createContext()` - in turn state
|
||||
|
||||
### Context Transformation
|
||||
|
||||
```typescript
|
||||
// 1. transformContext() hook (AgentMessage[])
|
||||
let messages = context.messages;
|
||||
if (config.transformContext) {
|
||||
messages = await config.transformContext(messages, signal);
|
||||
}
|
||||
|
||||
// 2. convertToLlm() hook (AgentMessage[] → Message[])
|
||||
const llmMessages = await config.convertToLlm(messages);
|
||||
|
||||
// 3. Build LLM context (Message[])
|
||||
const llmContext: Context = {
|
||||
systemPrompt: context.systemPrompt,
|
||||
messages: llmMessages,
|
||||
tools: context.tools
|
||||
};
|
||||
```
|
||||
|
||||
### Context Transformations
|
||||
|
||||
**Example: Prune old messages**
|
||||
|
||||
```typescript
|
||||
transformContext: async (messages) => {
|
||||
if (estimateTokens(messages) > MAX_TOKENS) {
|
||||
// Find cut point (preserve recent turns)
|
||||
const cutIndex = findCutPoint(messages, MAX_TOKENS * 0.7);
|
||||
return messages.slice(cutIndex);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Inject external context**
|
||||
|
||||
```typescript
|
||||
transformContext: async (messages) => {
|
||||
const externalData = await fetchExternalData();
|
||||
const contextMessage: AgentMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: externalData }],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
return [contextMessage, ...messages];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hook Context Flow
|
||||
|
||||
### Hook Parameter Flow
|
||||
|
||||
```
|
||||
Agent.prompt()
|
||||
│
|
||||
├─► transformContext(messages) [AgentLoopConfig]
|
||||
│ └─► Messages before LLM call
|
||||
│
|
||||
├─► convertToLlm(messages)
|
||||
│ └─► Messages to send to LLM
|
||||
│
|
||||
├─► beforeToolCall(context) [AgentLoopConfig]
|
||||
│ ├─► assistantMessage
|
||||
│ ├─► toolCall
|
||||
│ ├─► args (validated)
|
||||
│ └─► context (AgentContext)
|
||||
│
|
||||
├─► afterToolCall(context) [AgentLoopConfig]
|
||||
│ ├─► assistantMessage
|
||||
│ ├─► toolCall
|
||||
│ ├─► args
|
||||
│ ├─► result (executed)
|
||||
│ ├─► isError
|
||||
│ └─► context (AgentContext)
|
||||
│
|
||||
├─► shouldStopAfterTurn(context) [AgentLoopConfig]
|
||||
│ ├─► message (assistant)
|
||||
│ ├─► toolResults
|
||||
│ ├─► context (AgentContext)
|
||||
│ └─► newMessages
|
||||
│
|
||||
├─► prepareNextTurn(context) [AgentLoopConfig]
|
||||
│ └─► Return: context/model/thinkingLevel
|
||||
│
|
||||
├─► getSteeringMessages() [AgentLoopConfig]
|
||||
│ └─► Messages to inject now
|
||||
│
|
||||
└─► getFollowUpMessages() [AgentLoopConfig]
|
||||
└─► Messages for after agent stops
|
||||
```
|
||||
|
||||
### Hook Return Value Flow
|
||||
|
||||
```
|
||||
beforeToolCall()
|
||||
│
|
||||
├─► { block: true, reason } → Error tool result
|
||||
└─► undefined → Allow execution
|
||||
│
|
||||
▼
|
||||
tool.execute()
|
||||
│
|
||||
▼
|
||||
afterToolCall()
|
||||
│
|
||||
├─► Override: content, details, isError, usage, terminate
|
||||
└─► undefined → Use executed result
|
||||
│
|
||||
▼
|
||||
Emit: tool_execution_end
|
||||
│
|
||||
▼
|
||||
Create: ToolResultMessage
|
||||
│
|
||||
▼
|
||||
Emit: message_start/end (toolResult)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Queue Flow
|
||||
|
||||
### Steering Queue
|
||||
|
||||
**Purpose**: Interrupt agent while working.
|
||||
|
||||
**Flow**:
|
||||
```
|
||||
steer("New instruction")
|
||||
│
|
||||
▼
|
||||
steeringQueue.enqueue(message)
|
||||
│
|
||||
▼
|
||||
After turn ends:
|
||||
│
|
||||
├─► getSteeringMessages() called
|
||||
│ ├─► Drain queue (mode: "all" or "one-at-a-time")
|
||||
│ └─► Return messages
|
||||
│
|
||||
▼
|
||||
Inject messages into context
|
||||
│
|
||||
▼
|
||||
Next LLM call includes steering messages
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// User types while agent is working
|
||||
agent.steer("Wait, check this file first");
|
||||
|
||||
// Agent finishes current work
|
||||
// → Steering messages injected
|
||||
// → LLM sees: [original, ..., new user message]
|
||||
```
|
||||
|
||||
### Follow-up Queue
|
||||
|
||||
**Purpose**: Queue messages for after agent stops naturally.
|
||||
|
||||
**Flow**:
|
||||
```
|
||||
followUp("Next task")
|
||||
│
|
||||
▼
|
||||
followUpQueue.enqueue(message)
|
||||
│
|
||||
▼
|
||||
Agent would stop (no more tool calls)
|
||||
│
|
||||
├─► getFollowUpMessages() called
|
||||
│ ├─► Drain queue
|
||||
│ └─► Return messages
|
||||
│
|
||||
▼
|
||||
Set as pendingMessages
|
||||
│
|
||||
▼
|
||||
Inner loop continues
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
agent.followUp("Now create a README");
|
||||
|
||||
// Agent finishes current task
|
||||
// → Follow-up messages injected
|
||||
// → Agent continues with new task
|
||||
```
|
||||
|
||||
### Queue Modes
|
||||
|
||||
**"all" Mode**:
|
||||
```
|
||||
Queued: [msg1, msg2, msg3]
|
||||
│
|
||||
▼
|
||||
Drain: [msg1, msg2, msg3]
|
||||
│
|
||||
▼
|
||||
All injected together
|
||||
```
|
||||
|
||||
**"one-at-a-time" Mode**:
|
||||
```
|
||||
Queued: [msg1, msg2, msg3]
|
||||
│
|
||||
▼
|
||||
Drain: [msg1]
|
||||
│
|
||||
▼
|
||||
msg1 injected, msg2, msg3 remain
|
||||
│
|
||||
▼
|
||||
After next turn:
|
||||
│
|
||||
▼
|
||||
Drain: [msg2]
|
||||
│
|
||||
▼
|
||||
... and so on
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Flow
|
||||
|
||||
### Session Tree Structure
|
||||
|
||||
```
|
||||
root (parentId: null)
|
||||
├─► message [id: 1, parentId: null]
|
||||
│ └─► message [id: 2, parentId: 1]
|
||||
│ └─► tool_result [id: 3, parentId: 2]
|
||||
│ └─► message [id: 4, parentId: 3]
|
||||
│ └─► compaction [id: 5, parentId: 4]
|
||||
│ ├─► retained: [msg6, msg7]
|
||||
│ └─► message [id: 8, parentId: 5]
|
||||
│ └─► leaf [id: 9, parentId: 8]
|
||||
```
|
||||
|
||||
### Context Building
|
||||
|
||||
```typescript
|
||||
async function buildContext(session: Session): Promise<SessionContext> {
|
||||
// 1. Get path from leaf to root
|
||||
const pathEntries = await session.getBranch();
|
||||
// [root, msg1, msg2, toolResult, msg4, compaction, msg8, leaf]
|
||||
|
||||
// 2. Apply default transform (compaction logic)
|
||||
const contextEntries = defaultContextEntryTransform(pathEntries);
|
||||
// [compaction, retainedTail..., msg8]
|
||||
|
||||
// 3. Project entries to messages
|
||||
const messages = contextEntries.flatMap(sessionEntryToContextMessages);
|
||||
// [compactionSummary, retainedMsgs..., msg8]
|
||||
|
||||
// 4. Derive state
|
||||
const state = deriveSessionContextState(pathEntries);
|
||||
// { model, thinkingLevel, activeToolNames }
|
||||
|
||||
return { ...state, messages };
|
||||
}
|
||||
```
|
||||
|
||||
### Session Entry Types
|
||||
|
||||
| Type | Stored When |
|
||||
|------|-------------|
|
||||
| `message` | Every user/assistant/toolResult |
|
||||
| `model_change` | `setModel()` called |
|
||||
| `thinking_level_change` | `setThinkingLevel()` called |
|
||||
| `active_tools_change` | `setActiveTools()` called |
|
||||
| `compaction` | `compact()` called |
|
||||
| `branch_summary` | Branching with summary |
|
||||
| `custom` | `appendCustomEntry()` |
|
||||
| `custom_message` | `appendCustomMessageEntry()` |
|
||||
| `label` | `appendLabel()` |
|
||||
| `leaf` | `setLeafId()` |
|
||||
| `session_info` | `appendSessionName()` |
|
||||
|
||||
### Pending Writes
|
||||
|
||||
During active turns, writes are buffered:
|
||||
|
||||
```typescript
|
||||
async function appendMessage(message: AgentMessage): Promise<void> {
|
||||
if (phase === "idle") {
|
||||
// Direct write
|
||||
await session.appendMessage(message);
|
||||
} else {
|
||||
// Buffer for later
|
||||
pendingSessionWrites.push({ type: "message", message });
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPendingSessionWrites(): Promise<void> {
|
||||
while (pendingSessionWrites.length > 0) {
|
||||
const write = pendingSessionWrites.shift();
|
||||
|
||||
if (write.type === "message") {
|
||||
await session.appendMessage(write.message);
|
||||
} else if (write.type === "model_change") {
|
||||
await session.appendModelChange(...);
|
||||
}
|
||||
// ... other types
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Execution State Flow
|
||||
|
||||
### Tool Call State
|
||||
|
||||
```typescript
|
||||
interface BeforeToolCallContext {
|
||||
assistantMessage: AssistantMessage;
|
||||
toolCall: AgentToolCall;
|
||||
args: unknown; // Validated
|
||||
context: AgentContext; // Snapshot
|
||||
}
|
||||
|
||||
interface AfterToolCallContext {
|
||||
assistantMessage: AssistantMessage;
|
||||
toolCall: AgentToolCall;
|
||||
args: unknown;
|
||||
result: AgentToolResult<any>; // Executed
|
||||
isError: boolean;
|
||||
context: AgentContext;
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Result State
|
||||
|
||||
```typescript
|
||||
interface AgentToolResult<T> {
|
||||
content: (TextContent | ImageContent)[]; // To model
|
||||
details: T; // For logs/UI
|
||||
usage?: Usage; // Tool-specific
|
||||
addedToolNames?: string[]; // New tools
|
||||
terminate?: boolean; // Early stop hint
|
||||
}
|
||||
```
|
||||
|
||||
### State Transition
|
||||
|
||||
```
|
||||
Tool Call from LLM
|
||||
│
|
||||
▼
|
||||
prepareToolCall()
|
||||
├─► Find tool
|
||||
├─► Validate args
|
||||
└─► beforeToolCall()
|
||||
├─► block: true → Error
|
||||
└─► block: undefined → Continue
|
||||
│
|
||||
▼
|
||||
tool.execute()
|
||||
├─► onUpdate(partialResult)
|
||||
└─► Return final result
|
||||
│
|
||||
▼
|
||||
afterToolCall()
|
||||
├─► Override result
|
||||
└─► Use executed result
|
||||
│
|
||||
▼
|
||||
createToolResultMessage()
|
||||
│
|
||||
▼
|
||||
Emit: tool_execution_end
|
||||
│
|
||||
▼
|
||||
Emit: message_start/end (toolResult)
|
||||
│
|
||||
▼
|
||||
Push to context.messages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Abort Flow
|
||||
|
||||
### Abort Signal Propagation
|
||||
|
||||
```typescript
|
||||
// 1. Create abort controller
|
||||
const abortController = new AbortController();
|
||||
|
||||
// 2. Pass to all async operations
|
||||
await runAgentLoop(..., abortController.signal, ...);
|
||||
|
||||
// 3. Check signal in long operations
|
||||
execute: async (id, params, signal, onUpdate) => {
|
||||
for await (const item of longProcess()) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Abort
|
||||
abortController.abort();
|
||||
```
|
||||
|
||||
### Abort in Hooks
|
||||
|
||||
```typescript
|
||||
// Check signal at start
|
||||
beforeToolCall: async ({ toolCall }, signal) => {
|
||||
if (signal?.aborted) {
|
||||
return { block: true, reason: "Operation aborted" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check signal in async operations
|
||||
transformContext: async (messages, signal) => {
|
||||
if (signal?.aborted) {
|
||||
return messages; // Return safe fallback
|
||||
}
|
||||
|
||||
// Long operation
|
||||
const result = await expensiveTransform(messages, signal);
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AGENT LIFECYCLE │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Agent.prompt("Hello") │
|
||||
│ │ │
|
||||
│ ├─► agent_start (event) │
|
||||
│ ├─► turn_start (event) │
|
||||
│ ├─► message_start (user) (event) │
|
||||
│ ├─► message_end (user) (event) │
|
||||
│ │ │
|
||||
│ ├─► streamAssistantResponse() │
|
||||
│ │ ├─► message_start (assistant) (event) │
|
||||
│ │ ├─► message_update (text chunk 1) (event) │
|
||||
│ │ ├─► message_update (text chunk 2) (event) │
|
||||
│ │ ├─► message_update (toolCall) (event) │
|
||||
│ │ └─► message_end (assistant) (event) │
|
||||
│ │ │
|
||||
│ ├─► executeToolCalls() │
|
||||
│ │ ├─► tool_execution_start (event) │
|
||||
│ │ ├─► tool_execute() │
|
||||
│ │ │ └─► onUpdate(partial) (event) │
|
||||
│ │ ├─► tool_execution_end (event) │
|
||||
│ │ └─► message_start/end (toolResult) (events) │
|
||||
│ │ │
|
||||
│ ├─► turn_end (event) │
|
||||
│ │ ├─► Should stop? → agent_end │
|
||||
│ │ └─► Drain queues → another turn │
|
||||
│ │ │
|
||||
│ └─► agent_end (event) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Key data flows**:
|
||||
1. Input messages → Normalized → Agent messages
|
||||
2. Agent messages → Context transform → LLM messages
|
||||
3. LLM response → Streamed → Agent messages
|
||||
4. Tool calls → Executed → Tool results → Agent messages
|
||||
5. All messages → Session storage → Tree structure
|
||||
|
||||
**State management**:
|
||||
- Agent: In-memory state with mutation on events
|
||||
- Session: Persistent tree with entries
|
||||
- Hooks: Transform data at key points
|
||||
|
||||
**Queue system**:
|
||||
- Steering: Interrupt current work
|
||||
- Follow-up: Queue for after agent stops
|
||||
- Modes: "all" or "one-at-a-time"
|
||||
@@ -0,0 +1,522 @@
|
||||
# Learning Path and Study Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide helps you learn the Pi Agent architecture **top-down**, starting from high-level concepts to implementation details.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Big Picture (1-2 hours)
|
||||
|
||||
### Goal: Understand how components fit together
|
||||
|
||||
### Resources
|
||||
1. **01-ARCHITECTURE-OVERVIEW.md** - Read this first
|
||||
2. **Diagrams** - Study the architecture diagrams
|
||||
|
||||
### Key Questions to Answer
|
||||
|
||||
✅ What are the two main layers?
|
||||
✅ What does each layer do?
|
||||
✅ How do messages flow through the system?
|
||||
✅ What is the relationship between Agent and AgentHarness?
|
||||
✅ What are the main event types?
|
||||
✅ How do tools integrate with the agent?
|
||||
✅ What is the purpose of hooks?
|
||||
|
||||
### Exercises
|
||||
|
||||
1. Draw the architecture diagram from memory
|
||||
2. List 3 use cases for each hook type
|
||||
3. Trace a message from input to LLM to output
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Agent (2-3 hours)
|
||||
|
||||
### Goal: Understand the low-level agent loop
|
||||
|
||||
### Resources
|
||||
1. **02-AGENT-LOOP-DETAILED.md** - Study the agent loop
|
||||
2. Read `src/agent-loop.ts` (skim, focus on comments)
|
||||
3. Read `src/types.ts` - Understand AgentEvent, AgentMessage, AgentTool
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **runAgentLoop()** - Starts a new conversation
|
||||
- **runAgentLoopContinue()** - Continues existing conversation
|
||||
- **runLoop()** - Main iteration (outer and inner loops)
|
||||
- **streamAssistantResponse()** - Streams LLM response
|
||||
- **executeToolCalls()** - Executes tool calls
|
||||
- **prepareToolCall()** - Validates and prepares tools
|
||||
- **executePreparedToolCall()** - Executes tool with updates
|
||||
- **finalizeExecutedToolCall()** - Finalizes with hooks
|
||||
|
||||
### Key Questions to Answer
|
||||
|
||||
✅ What's the difference between outer and inner loop?
|
||||
✅ How does streaming work?
|
||||
✅ How are tool calls executed (sequential vs parallel)?
|
||||
✅ What happens when a tool is blocked?
|
||||
✅ How are errors handled?
|
||||
✅ What are the four phases of tool execution?
|
||||
✅ How does the loop know when to stop?
|
||||
|
||||
### Exercises
|
||||
|
||||
1. Trace through a conversation with 1 prompt + 2 tool calls
|
||||
2. Draw the outer/inner loop flow
|
||||
3. Explain how abort signals propagate
|
||||
4. Explain queue draining (steering/follow-up)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Hooks System (2-3 hours)
|
||||
|
||||
### Goal: Understand how to customize agent behavior
|
||||
|
||||
### Resources
|
||||
1. **03-HOOK-SYSTEM.md** - Study all hooks
|
||||
2. Read `src/types.ts` - Hook types and contexts
|
||||
|
||||
### Hook Categories
|
||||
|
||||
**Message Transformation**:
|
||||
- `convertToLlm` - Convert messages to LLM format
|
||||
- `transformContext` - Manipulate context before LLM
|
||||
|
||||
**Lifecycle Hooks**:
|
||||
- `beforeToolCall` - Block or modify tool execution
|
||||
- `afterToolCall` - Override tool results
|
||||
- `shouldStopAfterTurn` - Request early termination
|
||||
- `prepareNextTurn` - Update context/model/thinking
|
||||
|
||||
**Queue Draining**:
|
||||
- `getSteeringMessages` - Interrupt agent mid-work
|
||||
- `getFollowUpMessages` - Queue messages for later
|
||||
|
||||
### Key Questions to Answer
|
||||
|
||||
✅ What hooks receive abort signals?
|
||||
✅ What hooks can block execution?
|
||||
✅ What is the execution order of hooks?
|
||||
✅ What's the difference between beforeToolCall and afterToolCall?
|
||||
✅ How do you implement context window management?
|
||||
✅ How do you implement permission checks?
|
||||
✅ What's the difference between steering and follow-up?
|
||||
|
||||
### Exercises
|
||||
|
||||
1. Implement a hook that logs all tool calls
|
||||
2. Implement a hook that blocks dangerous commands
|
||||
3. Implement a hook that summarizes conversation every 5 turns
|
||||
4. Implement a hook that switches to high thinking for complex tasks
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: AgentHarness (3-4 hours)
|
||||
|
||||
### Goal: Understand high-level API and session management
|
||||
|
||||
### Resources
|
||||
1. **06-AGENTHARNESS-REFERENCE.md** - Study the harness API
|
||||
2. Read `src/harness/agent-harness.ts` (focus on public methods)
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Session** - Persistent conversation history
|
||||
- **SessionTreeEntry** - Individual entries in conversation
|
||||
- **Context Building** - Derive LLM context from session
|
||||
- **Branching** - Create new conversation paths
|
||||
- **Compaction** - Summarize old history
|
||||
|
||||
### API Methods
|
||||
|
||||
**Core**:
|
||||
- `prompt()` - Run new conversation
|
||||
- `skill()` - Execute skill
|
||||
- `promptFromTemplate()` - Run template
|
||||
|
||||
**Queues**:
|
||||
- `steer()` - Interrupt agent
|
||||
- `followUp()` - Queue message
|
||||
- `nextTurn()` - Queue for next turn
|
||||
|
||||
**Session**:
|
||||
- `compact()` - Compress context
|
||||
- `navigateTree()` - Branch conversation
|
||||
|
||||
**State**:
|
||||
- `setModel()` - Change model
|
||||
- `setThinkingLevel()` - Change reasoning level
|
||||
- `setTools()` / `setActiveTools()` - Manage tools
|
||||
|
||||
### Key Questions to Answer
|
||||
|
||||
✅ What's the difference between steer() and followUp()?
|
||||
✅ How does branching work?
|
||||
✅ How does compaction work?
|
||||
✅ What's the relationship between Session and SessionStorage?
|
||||
✅ What's the difference between MessageEntry and CustomEntry?
|
||||
✅ How are pending writes handled during active turns?
|
||||
✅ What hooks does AgentHarness provide?
|
||||
|
||||
### Exercises
|
||||
|
||||
1. Create a session, add messages, and build context
|
||||
2. Implement branching and navigate between branches
|
||||
3. Implement compaction and verify it works
|
||||
4. Set up hooks for tool call logging
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Session Architecture (2-3 hours)
|
||||
|
||||
### Goal: Understand persistence and tree structure
|
||||
|
||||
### Resources
|
||||
1. **04-SESSION-ARCHITECTURE.md** - Study session system
|
||||
2. Read `src/harness/session/session.ts`
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **SessionTreeEntry** - Tree nodes
|
||||
- **Path Tracing** - From leaf to root
|
||||
- **Context Building** - Projection to messages
|
||||
- **Default Transform** - Compaction logic
|
||||
- **Forking** - Create branches
|
||||
|
||||
### Key Questions to Answer
|
||||
|
||||
✅ How is conversation history stored?
|
||||
✅ What's the difference between ID and parentId?
|
||||
✅ How does the session know the current head?
|
||||
✅ What entries appear in the LLM context?
|
||||
✅ How does compaction work at the session level?
|
||||
✅ What's the difference between fork and navigateTree()?
|
||||
✅ How are custom entries different from messages?
|
||||
|
||||
### Exercises
|
||||
|
||||
1. Create a session and trace its tree
|
||||
2. Add custom entries and verify they don't appear in context
|
||||
3. Fork a session and compare contexts
|
||||
4. Compact a session and verify size reduction
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Tool Execution (2-3 hours)
|
||||
|
||||
### Goal: Understand how tools work
|
||||
|
||||
### Resources
|
||||
1. **05-TOOL-EXECUTION.md** - Study tool system
|
||||
2. Read `src/harness/tools/` - Built-in tools
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **AgentTool** - Tool definition
|
||||
- **Tool Execution Flow** - Prepare → Execute → Finalize
|
||||
- **Sequential vs Parallel** - Execution modes
|
||||
- **Streaming Updates** - Progress updates
|
||||
- **Error Handling** - Throw vs return error
|
||||
|
||||
### Tool Execution Flow
|
||||
|
||||
```
|
||||
prepareToolCall()
|
||||
├─ Find tool
|
||||
├─ Validate args
|
||||
└─ beforeToolCall()
|
||||
|
||||
executePreparedToolCall()
|
||||
└─ tool.execute()
|
||||
|
||||
finalizeExecutedToolCall()
|
||||
└─ afterToolCall()
|
||||
|
||||
emitToolResult()
|
||||
```
|
||||
|
||||
### Key Questions to Answer
|
||||
|
||||
✅ What's the difference between prepareArguments and execute?
|
||||
✅ How do streaming updates work?
|
||||
✅ When do you throw vs return an error?
|
||||
✅ How are sequential vs parallel tools different?
|
||||
✅ What's in the ToolContext passed to execute()?
|
||||
✅ How do you handle long-running operations?
|
||||
✅ What's the terminate flag for?
|
||||
|
||||
### Exercises
|
||||
|
||||
1. Implement a custom tool (e.g., weather API)
|
||||
2. Implement streaming updates for long operation
|
||||
3. Implement tool with error handling
|
||||
4. Test sequential vs parallel execution
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Data Flow (2-3 hours)
|
||||
|
||||
### Goal: Understand how data flows through the system
|
||||
|
||||
### Resources
|
||||
1. **07-DATA-FLOW-STATE.md** - Study data flow
|
||||
2. Read `src/agent.ts` - State management
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **AgentMessage** - Unified message type
|
||||
- **AgentEvent** - Event stream
|
||||
- **AgentContext** - Snapshot for LLM
|
||||
- **State Mutation** - How state changes on events
|
||||
- **Queue Flow** - Steering and follow-up
|
||||
|
||||
### Key Questions to Answer
|
||||
|
||||
✅ How do messages flow from input to LLM?
|
||||
✅ How is state mutated on events?
|
||||
✅ What's the difference between AgentContext and AgentState?
|
||||
✅ How do hooks transform data?
|
||||
✅ How are abort signals propagated?
|
||||
✅ What's the relationship between queue mode and draining?
|
||||
✅ How are pending writes handled?
|
||||
|
||||
### Exercises
|
||||
|
||||
1. Trace a message through the entire flow
|
||||
2. Trace a tool call through all hooks
|
||||
3. Trace an abort through the system
|
||||
4. Draw the complete data flow diagram
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Implementation (4-6 hours)
|
||||
|
||||
### Goal: Implement your own version
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Design your data structures** (in Julia)
|
||||
- AgentMessage equivalent
|
||||
- AgentEvent equivalent
|
||||
- AgentTool equivalent
|
||||
|
||||
2. **Implement core agent loop**
|
||||
- Message streaming
|
||||
- Tool execution
|
||||
- Event emission
|
||||
|
||||
3. **Add hooks system**
|
||||
- Hook registration
|
||||
- Hook execution
|
||||
- Return value handling
|
||||
|
||||
4. **Implement session persistence**
|
||||
- Tree structure
|
||||
- Entry types
|
||||
- Context building
|
||||
|
||||
5. **Add harness layer**
|
||||
- High-level API
|
||||
- Queue management
|
||||
- Branching
|
||||
|
||||
### Recommended Order
|
||||
|
||||
```
|
||||
1. Data Types (2h)
|
||||
├─ AgentMessage
|
||||
├─ AgentEvent
|
||||
└─ AgentTool
|
||||
|
||||
2. Core Loop (4h)
|
||||
├─ streamAssistantResponse
|
||||
├─ executeToolCalls
|
||||
└─ runLoop
|
||||
|
||||
3. State Management (2h)
|
||||
├─ AgentState
|
||||
└─ Event handlers
|
||||
|
||||
4. Hooks (3h)
|
||||
├─ Hook system
|
||||
└─ Implement hooks
|
||||
|
||||
5. Session (4h)
|
||||
├─ Tree structure
|
||||
├─ Persistence
|
||||
└─ Context building
|
||||
|
||||
6. Harness (4h)
|
||||
├─ Public API
|
||||
├─ Queue management
|
||||
└─ Branching
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- Start simple, iterate
|
||||
- Test each component
|
||||
- Follow TypeScript patterns
|
||||
- Use your language's idioms
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Agent Layer
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `runAgentLoop()` | Start new conversation |
|
||||
| `runAgentLoopContinue()` | Continue existing |
|
||||
| `runLoop()` | Main iteration |
|
||||
| `streamAssistantResponse()` | Stream LLM |
|
||||
| `executeToolCalls()` | Execute tools |
|
||||
|
||||
### AgentHarness Layer
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `prompt()` | Run conversation |
|
||||
| `steer()` | Interrupt agent |
|
||||
| `followUp()` | Queue message |
|
||||
| `compact()` | Compress context |
|
||||
| `navigateTree()` | Branch conversation |
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `convertToLlm` | Convert messages |
|
||||
| `transformContext` | Manipulate context |
|
||||
| `beforeToolCall` | Block tools |
|
||||
| `afterToolCall` | Override results |
|
||||
| `shouldStopAfterTurn` | Request stop |
|
||||
| `prepareNextTurn` | Update config |
|
||||
| `getSteeringMessages` | Interrupt |
|
||||
| `getFollowUpMessages` | Queue for later |
|
||||
|
||||
### Entry Types
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `message` | User/assistant/toolResult |
|
||||
| `model_change` | Model switch |
|
||||
| `thinking_level_change` | Reasoning level |
|
||||
| `active_tools_change` | Tools change |
|
||||
| `compaction` | History summary |
|
||||
| `branch_summary` | Branch marker |
|
||||
| `custom` | App data |
|
||||
| `custom_message` | Custom message |
|
||||
| `label` | User label |
|
||||
| `leaf` | Current head |
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Context Window Management
|
||||
|
||||
```typescript
|
||||
transformContext: async (messages, signal) => {
|
||||
if (estimateTokens(messages) > MAX_TOKENS) {
|
||||
return pruneOldMessages(messages);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Tool Permission Checks
|
||||
|
||||
```typescript
|
||||
beforeToolCall: async ({ toolCall, args }, signal) => {
|
||||
if (toolCall.name === "bash" && !await canExecute(args)) {
|
||||
return { block: true, reason: "Permission denied" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Streaming Updates
|
||||
|
||||
```typescript
|
||||
execute: async (id, params, signal, onUpdate) => {
|
||||
for await (const chunk of process()) {
|
||||
onUpdate({ content: [{ type: "text", text: `Progress: ${chunk}%` }] });
|
||||
}
|
||||
return finalResult;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Branching
|
||||
|
||||
```typescript
|
||||
const branchSession = await session.fork(leafId);
|
||||
const branchHarness = new AgentHarness({ session: branchSession });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Study Schedule
|
||||
|
||||
| Week | Focus | Hours |
|
||||
|------|-------|-------|
|
||||
| 1 | Phases 1-2 | 6-8 |
|
||||
| 2 | Phases 3-4 | 8-10 |
|
||||
| 3 | Phases 5-6 | 6-8 |
|
||||
| 4 | Phase 7-8 | 8-10 |
|
||||
|
||||
**Total**: 28-36 hours
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
After understanding the architecture:
|
||||
|
||||
1. **Implement in Julia**
|
||||
- Start with data types
|
||||
- Implement core loop
|
||||
- Add hooks
|
||||
- Implement session
|
||||
|
||||
2. **Extend Functionality**
|
||||
- Add new tool types
|
||||
- Implement custom hooks
|
||||
- Add new entry types
|
||||
|
||||
3. **Optimize**
|
||||
- Improve token estimation
|
||||
- Optimize context pruning
|
||||
- Parallelize operations
|
||||
|
||||
4. **Production**
|
||||
- Error handling
|
||||
- Logging
|
||||
- Monitoring
|
||||
|
||||
---
|
||||
|
||||
## Questions to Test Understanding
|
||||
|
||||
1. How would you implement a tool that requires user approval?
|
||||
2. How would you implement conversation summarization every 10 turns?
|
||||
3. How would you implement context pruning based on importance?
|
||||
4. How would you implement branching with automatic summaries?
|
||||
5. How would you implement tool execution rate limiting?
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Top-down learning**:
|
||||
1. Big picture (layers, components)
|
||||
2. Core agent (loop, streaming)
|
||||
3. Hooks (customization)
|
||||
4. Harness (session, persistence)
|
||||
5. Data flow (how everything connects)
|
||||
|
||||
**Key insight**: The system is built on **messages** and **events** with hooks for customization.
|
||||
@@ -0,0 +1,695 @@
|
||||
# Pi Agent Architecture - Visual Diagrams
|
||||
|
||||
## 1. System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION LAYER │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Agent User │ │ AgentHarness │ │ AgentHarness │ │
|
||||
│ │ (Low-Level) │ │ (High-Level) │ │ (Custom App) │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────┬───────────────┴───────────────────────┬┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Agent Core │ │ AgentHarness │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • State mgmt │ │ • Session │ │
|
||||
│ │ • Event stream │ │ • Compaction │ │
|
||||
│ │ • Queue mgmt │ │ • Branching │ │
|
||||
│ │ • Hook system │ │ • Skills │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
└────────────────────┼─────────────────────────────────────┼─────────────────────────────────┘
|
||||
│ │
|
||||
┌────────────┴────────────┐ ┌──────────────┴──────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Agent Loop │ │ Agent Context │ │ Agent State │ │ Agent Event │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ • runAgentLoop │ │ • Messages │ │ • Tools │ │ • agent_start │
|
||||
│ • runLoop │ │ • System prompt │ │ • Messages │ │ • agent_end │
|
||||
│ • streamResponse │ │ • Tools │ │ • isStreaming │ │ • turn_start │
|
||||
│ • executeTools │ │ │ │ • pendingCalls │ │ • turn_end │
|
||||
└────────┬─────────┘ └──────────────────┘ └──────────────────┘ │ • message_start │
|
||||
│ │ • message_update │
|
||||
▼ │ • message_end │
|
||||
┌───────────────────────────────────────────────────────────────────────▼───────────────────┐
|
||||
│ AGENT CORE (agent.ts, agent-loop.ts) │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SESSION LAYER │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Session │ │ SessionStorage │ │ SessionRepo │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ • Tree structure │ │ • Memory │ │ • Create │ │
|
||||
│ │ • Context build │ │ • JSONL │ │ • Open │ │
|
||||
│ │ • Branching │ │ │ │ • List │ │
|
||||
│ │ • Compaction │ │ │ │ • Fork │ │
|
||||
│ └────────┬─────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Session Tree │ │
|
||||
│ │ │ │
|
||||
│ │ root (null) │ │
|
||||
│ │ ├─ message [id:1] ← User prompt │ │
|
||||
│ │ │ └─ message [id:2] ← Assistant response │ │
|
||||
│ │ │ └─ tool_result [id:3] ← Tool call result │ │
|
||||
│ │ │ └─ message [id:4] ← User continuation │ │
|
||||
│ │ │ └─ compaction [id:5] ← History summarized │ │
|
||||
│ │ │ ├─ retained: [msg6, msg7] ← Recent messages kept │ │
|
||||
│ │ │ └─ message [id:8] ← After compaction │ │
|
||||
│ │ │ └─ leaf [id:9] ← Current head (cursor) │ │
|
||||
│ │ │ │ │
|
||||
│ │ └─ branch_summary [id:10] ← Branch point with summary │ │
|
||||
│ │ └─ message [id:11] ← New branch message │ │
|
||||
│ │ └─ leaf [id:12] ← New branch head │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ LLM PROVIDER LAYER │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ StreamFn │ │ Models API │ │ Provider API │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ • streamSimple │ │ • completeSimple │ │ • OpenAI │ │
|
||||
│ │ • completeSimple │ │ • Models catalog │ │ • Anthropic │ │
|
||||
│ │ │ │ │ │ • Custom │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Message Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ PROMPT FLOW │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
User Input
|
||||
│
|
||||
├─► string: "Build a web app"
|
||||
│
|
||||
├─► AgentMessage: { role: "user", content: [...] }
|
||||
│
|
||||
└─► AgentMessage[]: [{...}, {...}]
|
||||
│
|
||||
▼
|
||||
Agent.prompt(input)
|
||||
│
|
||||
├─► normalizePromptInput()
|
||||
│ ├─► string → { role: "user", content: [{ type: "text", text: input }] }
|
||||
│ ├─► AgentMessage → [message]
|
||||
│ └─► AgentMessage[] → messages
|
||||
│
|
||||
└─► runPromptMessages()
|
||||
│
|
||||
└─► runWithLifecycle()
|
||||
│
|
||||
├─► Set isStreaming = true
|
||||
├─► Create abort controller
|
||||
│
|
||||
└─► runAgentLoop()
|
||||
│
|
||||
├─► emit: agent_start
|
||||
├─► emit: turn_start
|
||||
├─► emit: message_start (user prompt)
|
||||
├─► emit: message_end (user prompt)
|
||||
│
|
||||
└─► runLoop()
|
||||
│
|
||||
├─► Check steering queue (drain if any)
|
||||
├─► Check follow-up queue (skip if first turn)
|
||||
│
|
||||
└─► streamAssistantResponse()
|
||||
│
|
||||
├─► transformContext() [optional]
|
||||
│ └─► AgentMessage[] → AgentMessage[]
|
||||
│
|
||||
├─► convertToLlm()
|
||||
│ └─► AgentMessage[] → Message[]
|
||||
│
|
||||
├─► Build LLM Context
|
||||
│ └─► { systemPrompt, messages, tools }
|
||||
│
|
||||
├─► Resolve API key (from hook)
|
||||
│
|
||||
└─► Call streamFn()
|
||||
│
|
||||
├─► LLM Provider API
|
||||
│
|
||||
└─► AssistantMessageEventStream
|
||||
│
|
||||
├─► message_start (assistant)
|
||||
├─► message_update (text chunk 1)
|
||||
├─► message_update (text chunk 2)
|
||||
├─► message_update (toolCall)
|
||||
└─► message_end (assistant)
|
||||
│
|
||||
└─► executeToolCalls()
|
||||
│
|
||||
├─► Sequential mode: tool calls one-by-one
|
||||
│
|
||||
└─► Parallel mode: tool calls concurrently
|
||||
│
|
||||
├─► prepareToolCall()
|
||||
│ ├─► Find tool by name
|
||||
│ ├─► prepareArguments() [optional]
|
||||
│ ├─► validateToolArguments()
|
||||
│ └─► beforeToolCall() hook
|
||||
│ ├─► Return {block: true, reason}
|
||||
│ └─► Return undefined
|
||||
│
|
||||
├─► executePreparedToolCall()
|
||||
│ ├─► onUpdate(partialResult) [streaming updates]
|
||||
│ └─► tool.execute()
|
||||
│
|
||||
└─► finalizeExecutedToolCall()
|
||||
└─► afterToolCall() hook
|
||||
├─► Override: content, details, isError, usage
|
||||
└─► Use executed result
|
||||
│
|
||||
└─► Emit: tool_execution_start/update/end
|
||||
│
|
||||
└─► Create ToolResultMessage
|
||||
│
|
||||
└─► Emit: message_start/end (toolResult)
|
||||
│
|
||||
└─► turn_end
|
||||
│
|
||||
├─► prepareNextTurn() hook
|
||||
│ └─► Return: context/model/thinkingLevel
|
||||
│
|
||||
├─► shouldStopAfterTurn() hook
|
||||
│ └─► Return: boolean
|
||||
│
|
||||
├─► Drain steering queue
|
||||
│ └─► getSteeringMessages() → inject
|
||||
│
|
||||
└─► Drain follow-up queue
|
||||
└─► getFollowUpMessages() → inject
|
||||
│
|
||||
├─► Steering/follow-up exists? → Repeat from streamAssistantResponse()
|
||||
└─► No more messages → emit: agent_end
|
||||
│
|
||||
└─► finishRun()
|
||||
└─► isStreaming = false
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CONTINUATION FLOW │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Agent.continue()
|
||||
│
|
||||
├─► Validate last message (must be user/toolResult)
|
||||
│
|
||||
└─► runAgentLoopContinue()
|
||||
│
|
||||
└─► runLoop() from current context (no new prompts)
|
||||
│
|
||||
└─► Same flow as above, starting from current context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Hook System Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ HOOK EXECUTION ORDER │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
AgentHarness.prompt()
|
||||
│
|
||||
├─► before_agent_start (harness hook)
|
||||
│ └─► Can return: messages, systemPrompt
|
||||
│
|
||||
├─► transformContext() (agent hook)
|
||||
│ └─► AgentMessage[] → AgentMessage[]
|
||||
│
|
||||
└─► streamAssistantResponse()
|
||||
│
|
||||
├─► before_provider_request (harness hook)
|
||||
│ └─► Can modify: streamOptions
|
||||
│
|
||||
├─► convertToLlm() (agent hook)
|
||||
│ └─► AgentMessage[] → Message[]
|
||||
│
|
||||
├─► streamFn()
|
||||
│
|
||||
└─► message_end (assistant)
|
||||
│
|
||||
└─► executeToolCalls()
|
||||
│
|
||||
├─► For each tool call:
|
||||
│
|
||||
│ ├─► tool_call (harness hook)
|
||||
│ │ └─► Can return: block, reason
|
||||
│ │
|
||||
│ ├─► executePreparedToolCall()
|
||||
│ │
|
||||
│ └─► tool_result (harness hook)
|
||||
│ └─► Can return: content, details, isError, usage, terminate
|
||||
│
|
||||
└─► turn_end
|
||||
│
|
||||
├─► shouldStopAfterTurn() (agent hook)
|
||||
│ └─► Return: boolean
|
||||
│
|
||||
├─► prepareNextTurn() (agent hook)
|
||||
│ └─► Return: context/model/thinkingLevel
|
||||
│
|
||||
├─► Drain steering queue
|
||||
│ └─► getSteeringMessages() (agent hook)
|
||||
│
|
||||
└─► Drain follow-up queue
|
||||
└─► getFollowUpMessages() (agent hook)
|
||||
│
|
||||
├─► Continue? → Repeat from streamAssistantResponse()
|
||||
└─► Stop? → agent_end (harness hook)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Tool Execution Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ TOOL EXECUTION FLOW │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Assistant Message with Tool Call
|
||||
│
|
||||
├─► { type: "toolCall", id: "tc_123", name: "bash", arguments: { command: "ls" } }
|
||||
│
|
||||
▼
|
||||
prepareToolCall()
|
||||
│
|
||||
├─► Find tool in currentContext.tools
|
||||
│ └─► Not found? → immediate error result
|
||||
│
|
||||
├─► prepareToolCallArguments() [optional shim]
|
||||
│ └─► Transform arguments before validation
|
||||
│
|
||||
├─► validateToolArguments()
|
||||
│ └─► Validate against tool parameters schema
|
||||
│
|
||||
└─► beforeToolCall() hook
|
||||
│
|
||||
├─► Return { block: true, reason: "..." }
|
||||
│ └─► Emit: tool_execution_start/update/end (error)
|
||||
│ └─► Tool NOT executed
|
||||
│
|
||||
└─► Return undefined
|
||||
│
|
||||
▼
|
||||
executePreparedToolCall()
|
||||
│
|
||||
├─► tool.execute(toolCallId, validatedArgs, signal, onUpdate)
|
||||
│ │
|
||||
│ ├─► Long-running operation
|
||||
│ │ └─► onUpdate({ content: [...], details: {...} })
|
||||
│ │ └─► Emit: tool_execution_update
|
||||
│ │
|
||||
│ └─► Return: { content, details, usage, ... }
|
||||
│
|
||||
└─► Return: { result, isError }
|
||||
│
|
||||
▼
|
||||
finalizeExecutedToolCall()
|
||||
│
|
||||
└─► afterToolCall() hook
|
||||
│
|
||||
├─► Return override: { content, details, isError, usage, terminate }
|
||||
│ └─► Merge: result = { ...result, ...override }
|
||||
│
|
||||
└─► Return: { toolCall, result, isError }
|
||||
│
|
||||
▼
|
||||
emitToolExecutionEnd()
|
||||
│
|
||||
└─► Emit: tool_execution_end
|
||||
│
|
||||
▼
|
||||
createToolResultMessage()
|
||||
│
|
||||
└─► Create ToolResultMessage
|
||||
├─► toolCallId: tc_123
|
||||
├─► toolName: bash
|
||||
├─► content: result.content
|
||||
├─► details: result.details
|
||||
├─► usage: result.usage
|
||||
├─► isError: result.isError
|
||||
└─► timestamp: Date.now()
|
||||
│
|
||||
▼
|
||||
emitToolResultMessage()
|
||||
│
|
||||
├─► Emit: message_start (toolResult)
|
||||
└─► Emit: message_end (toolResult)
|
||||
│
|
||||
▼
|
||||
Push to context.messages
|
||||
│
|
||||
▼
|
||||
Available for next LLM call
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Session Tree Navigation
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SESSION BRANCHING │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Original Session Tree:
|
||||
│
|
||||
├─ root
|
||||
│ └─ message [user #1] [id: 1]
|
||||
│ └─ message [assistant #1] [id: 2]
|
||||
│ └─ tool_result [id: 3]
|
||||
│ └─ message [user #2] [id: 4]
|
||||
│ └─ leaf [id: 5] ← Current head
|
||||
│
|
||||
▼
|
||||
Navigate to entry [id: 2] with summarize=true
|
||||
│
|
||||
├─► Collect entries from leaf to target
|
||||
│ └─► [leaf, msg4, tool_result, msg2] (path)
|
||||
│
|
||||
├─► Common ancestor: root
|
||||
│
|
||||
├─► Entries to summarize: [msg4, tool_result]
|
||||
│
|
||||
├─► Generate branch summary via LLM
|
||||
│
|
||||
├─► Create branch_summary entry
|
||||
│ └─► { type: "branch_summary", summary: "...", fromId: 2 }
|
||||
│
|
||||
└─► Fork session at target [id: 2]
|
||||
│
|
||||
├─► Clone entries up to target
|
||||
│ └─► [root, msg1, msg2, branch_summary]
|
||||
│
|
||||
└─► Set new leaf to [id: 2]
|
||||
│
|
||||
▼
|
||||
New Session Tree:
|
||||
│
|
||||
├─ root
|
||||
│ └─ message [user #1] [id: 1]
|
||||
│ └─ message [assistant #1] [id: 2]
|
||||
│ └─ branch_summary [id: 6] ← New branch point
|
||||
│ └─ leaf [id: 7] ← New head
|
||||
│
|
||||
└─ Original branch (still exists)
|
||||
└─ message [user #2] [id: 4]
|
||||
└─ tool_result [id: 3]
|
||||
└─ leaf [id: 5] ← Old head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Context Window Compaction
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CONTEXT COMPACTION │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Original Context (10,000 tokens):
|
||||
│
|
||||
├─ message [user #1]
|
||||
├─ message [assistant #1]
|
||||
├─ tool_result [id: 1]
|
||||
├─ message [user #2]
|
||||
├─ message [assistant #2]
|
||||
├─ tool_result [id: 2]
|
||||
├─ message [user #3]
|
||||
├─ message [assistant #3]
|
||||
├─ tool_result [id: 3]
|
||||
├─ message [user #4]
|
||||
├─ message [assistant #4]
|
||||
├─ tool_result [id: 4]
|
||||
├─ message [user #5]
|
||||
├─ message [assistant #5]
|
||||
└─ leaf [current]
|
||||
│
|
||||
▼
|
||||
Compact (threshold: 8,000 tokens)
|
||||
│
|
||||
├─► prepareCompaction()
|
||||
│ │
|
||||
│ ├─► Estimate tokens: 10,000
|
||||
│ ├─► Target: 6,000 (80% of 8,000)
|
||||
│ ├─► Find cut point: after message [assistant #3]
|
||||
│ ├─► Messages to summarize: [msg1, msg2, ..., msg3]
|
||||
│ └─► Retained tail: [msg4, msg5, leaf]
|
||||
│
|
||||
├─► LLM call to generate summary
|
||||
│
|
||||
└─► Create compaction entry
|
||||
│
|
||||
├─► summary: "User asked X, assistant did Y, then Z..."
|
||||
├─► firstKeptEntryId: msg4.id
|
||||
├─► tokensBefore: 10,000
|
||||
├─► retainedTail: [msg4, msg5, leaf]
|
||||
└─► details: { readFiles: [...], modifiedFiles: [...] }
|
||||
│
|
||||
▼
|
||||
Persisted Session Tree:
|
||||
│
|
||||
├─ root
|
||||
│ └─ message [user #1]
|
||||
│ └─ ... (original entries)
|
||||
│ └─ compaction [id: new] ← New entry
|
||||
│ ├─ summary: "User asked X..."
|
||||
│ ├─ firstKeptEntryId: msg4.id
|
||||
│ ├─ tokensBefore: 10000
|
||||
│ ├─ retainedTail: [msg4, msg5, leaf]
|
||||
│ └─ details: {...}
|
||||
│ └─ msg4 [id: msg4]
|
||||
│ └─ message [assistant #4]
|
||||
│ └─ tool_result [id: 4]
|
||||
│ └─ message [user #5]
|
||||
│ └─ message [assistant #5]
|
||||
│ └─ leaf [id: leaf]
|
||||
│
|
||||
└─ Context for LLM:
|
||||
└─ [compaction summary, retainedTail messages]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. State Mutation Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ STATE MUTATION ON EVENTS │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Agent State:
|
||||
│
|
||||
├─ systemPrompt: string
|
||||
├─ model: Model
|
||||
├─ thinkingLevel: ThinkingLevel
|
||||
├─ tools: AgentTool[]
|
||||
├─ messages: AgentMessage[]
|
||||
├─ isStreaming: boolean
|
||||
├─ streamingMessage: AgentMessage? ← Partial assistant message
|
||||
├─ pendingToolCalls: Set<string> ← Currently executing
|
||||
└─ errorMessage: string?
|
||||
│
|
||||
▼
|
||||
Events and State Changes:
|
||||
│
|
||||
├─ agent_start
|
||||
│ ├─ isStreaming = true
|
||||
│ ├─ streamingMessage = undefined
|
||||
│ └─ errorMessage = undefined
|
||||
│
|
||||
├─ message_start (user/assistant/toolResult)
|
||||
│ └─ No state change (just event emission)
|
||||
│
|
||||
├─ message_update (assistant only)
|
||||
│ └─ streamingMessage = updatedMessage
|
||||
│
|
||||
├─ message_end
|
||||
│ ├─ streamingMessage = undefined
|
||||
│ └─ messages.push(message)
|
||||
│
|
||||
├─ tool_execution_start
|
||||
│ └─ pendingToolCalls.add(toolCallId)
|
||||
│
|
||||
├─ tool_execution_end
|
||||
│ └─ pendingToolCalls.delete(toolCallId)
|
||||
│
|
||||
├─ turn_end
|
||||
│ └─ if (message.errorMessage) errorMessage = message.errorMessage
|
||||
│
|
||||
└─ agent_end
|
||||
├─ streamingMessage = undefined
|
||||
└─ (run finishes, state cleared on finishRun())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Queue Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ QUEUE DRAINING FLOW │
|
||||
└─────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Steering Queue (mode: "one-at-a-time"):
|
||||
│
|
||||
├─ Queue: [msg1, msg2, msg3]
|
||||
│
|
||||
├─ After turn ends:
|
||||
│
|
||||
├─► getSteeringMessages()
|
||||
│ ├─► mode = "one-at-a-time"
|
||||
│ ├─► Drain: [msg1]
|
||||
│ └─► Queue remaining: [msg2, msg3]
|
||||
│
|
||||
├─► Inject msg1 into context
|
||||
│
|
||||
└─► Next LLM call includes: [...original, msg1]
|
||||
│
|
||||
▼
|
||||
After next turn:
|
||||
│
|
||||
├─► getSteeringMessages()
|
||||
│ ├─► Drain: [msg2]
|
||||
│ └─► Queue remaining: [msg3]
|
||||
│
|
||||
└─► Inject msg2 into context
|
||||
│
|
||||
└─► ... and so on until queue empty
|
||||
|
||||
Follow-up Queue (mode: "all"):
|
||||
│
|
||||
├─ Queue: [msg1, msg2, msg3]
|
||||
│
|
||||
├─ Agent would stop (no more tool calls)
|
||||
│
|
||||
├─► getFollowUpMessages()
|
||||
│ ├─► mode = "all"
|
||||
│ ├─► Drain: [msg1, msg2, msg3]
|
||||
│ └─► Queue remaining: []
|
||||
│
|
||||
├─► Set as pendingMessages
|
||||
│
|
||||
└─► Inner loop continues with: [...original, msg1, msg2, msg3]
|
||||
│
|
||||
└─► All three messages injected together
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Event Sequence Examples
|
||||
|
||||
### Example 1: Simple Prompt
|
||||
|
||||
```
|
||||
agent_start
|
||||
turn_start
|
||||
message_start (user: "Hello")
|
||||
message_end (user: "Hello")
|
||||
message_start (assistant: "")
|
||||
message_update (assistant: "H")
|
||||
message_update (assistant: "He")
|
||||
message_update (assistant: "Hel")
|
||||
message_update (assistant: "Hell")
|
||||
message_update (assistant: "Hello")
|
||||
message_end (assistant: "Hello")
|
||||
turn_end
|
||||
agent_end
|
||||
```
|
||||
|
||||
### Example 2: Tool Execution
|
||||
|
||||
```
|
||||
agent_start
|
||||
turn_start
|
||||
message_start (user: "List files")
|
||||
message_end (user: "List files")
|
||||
message_start (assistant: "")
|
||||
message_update (assistant: "")
|
||||
message_update (assistant: "")
|
||||
message_update (assistant: "<tool_call name=bash>")
|
||||
message_update (assistant: "<tool_call name=bash>")
|
||||
message_update (assistant: "<tool_call name=bash>")
|
||||
message_end (assistant: "<tool_call name=bash>")
|
||||
tool_execution_start (bash: { command: "ls -la" })
|
||||
tool_execution_update (bash: { progress: 0 })
|
||||
tool_execution_update (bash: { progress: 50 })
|
||||
tool_execution_update (bash: { progress: 100 })
|
||||
tool_execution_end (bash: { exitCode: 0 })
|
||||
message_start (toolResult: "drwxr-xr-x...")
|
||||
message_end (toolResult: "drwxr-xr-x...")
|
||||
turn_end
|
||||
agent_end
|
||||
```
|
||||
|
||||
### Example 3: Steering
|
||||
|
||||
```
|
||||
agent_start
|
||||
turn_start
|
||||
message_start (user: "Build app")
|
||||
message_end (user: "Build app")
|
||||
message_start (assistant: "")
|
||||
message_update (assistant: "Building...")
|
||||
turn_end
|
||||
│
|
||||
├─ User types: "Wait, use React"
|
||||
│
|
||||
├─► steer("Wait, use React")
|
||||
│ └─► steeringQueue.push(msg)
|
||||
│
|
||||
├─► getSteeringMessages()
|
||||
│ └─► Drain and return [msg]
|
||||
│
|
||||
├─► Inject into context
|
||||
│
|
||||
└─► Next turn with: [...original, "Wait, use React"]
|
||||
|
||||
turn_start
|
||||
message_start (user: "Wait, use React")
|
||||
message_end (user: "Wait, use React")
|
||||
message_start (assistant: "")
|
||||
message_update (assistant: "Using React...")
|
||||
turn_end
|
||||
agent_end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Key Diagrams**:
|
||||
1. System Architecture - Layers and components
|
||||
2. Message Flow - Complete prompt flow
|
||||
3. Hook System - Hook execution order
|
||||
4. Tool Execution - Tool call lifecycle
|
||||
5. Session Branching - Tree navigation
|
||||
6. Context Compaction - History summarization
|
||||
7. State Mutation - Event-driven state changes
|
||||
8. Queue Flow - Steering and follow-up draining
|
||||
9. Event Sequences - Real examples
|
||||
|
||||
These diagrams show how data flows through the agent system from user input to LLM response to tool execution and back.
|
||||
@@ -0,0 +1,403 @@
|
||||
# Quick Start Guide
|
||||
|
||||
## Learning the Pi Agent Architecture
|
||||
|
||||
This guide helps you quickly understand the agent system and prepare for reimplementation in Julia.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core Concepts (30 minutes)
|
||||
|
||||
### 1. Two-Layer Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
│ ┌──────────────┐ ┌─────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ Agent (Core) │ │ AgentHarness │ │ Your Custom App │ │
|
||||
│ └───────┬──────┘ └────────┬────────┘ └─────────┬─────────────┘ │
|
||||
└──────────┼──────────────────┼─────────────────────┼────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌─────────────────┐ ┌────────────────┐
|
||||
│ agent-loop.ts│ │ agent-harness.ts│ │ Session Repo │
|
||||
│ types.ts │ │ │ │ │
|
||||
└──────────────┘ └─────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
**Key Insight**:
|
||||
- **Agent Core** = Low-level async iteration (messages in, messages out)
|
||||
- **AgentHarness** = High-level session management with persistence
|
||||
|
||||
---
|
||||
|
||||
### 2. Core Data Types
|
||||
|
||||
```typescript
|
||||
// Message: Basic unit of conversation
|
||||
interface Message {
|
||||
role: "user" | "assistant" | "toolResult";
|
||||
content: (TextContent | ImageContent)[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Tool: Function the agent can call
|
||||
interface AgentTool {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: Schema;
|
||||
execute(toolCallId, params, signal, onUpdate): Promise<Result>;
|
||||
}
|
||||
|
||||
// Event: Notification of state changes
|
||||
type AgentEvent =
|
||||
| { type: "agent_start" }
|
||||
| { type: "agent_end"; messages: Message[] }
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: Message; toolResults: Message[] }
|
||||
| { type: "message_start"; message: Message }
|
||||
| { type: "message_update"; message: Message }
|
||||
| { type: "message_end"; message: Message }
|
||||
| { type: "tool_execution_start"; ... }
|
||||
| { type: "tool_execution_end"; ... };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Message Flow (45 minutes)
|
||||
|
||||
### The Agent Loop
|
||||
|
||||
```
|
||||
1. User Input
|
||||
└─► Agent.prompt("Hello")
|
||||
|
||||
2. Agent Start
|
||||
└─► emit: agent_start, turn_start, message_start/end (user)
|
||||
|
||||
3. LLM Streaming
|
||||
└─► streamAssistantResponse()
|
||||
└─► transformContext() → convertToLlm() → streamFn()
|
||||
|
||||
4. Tool Execution
|
||||
└─► executeToolCalls()
|
||||
└─► prepare → execute → finalize (for each tool)
|
||||
|
||||
5. Turn End
|
||||
└─► emit: turn_end
|
||||
└─► Check hooks, drain queues, decide next turn
|
||||
|
||||
6. Repeat or End
|
||||
└─► Loop continues until no more work
|
||||
```
|
||||
|
||||
### Key Insight
|
||||
|
||||
**Everything is a message**: User input, assistant response, tool calls, tool results, steering messages.
|
||||
|
||||
**Everything is an event**: State changes are emitted as events for UI updates.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Hooks System (30 minutes)
|
||||
|
||||
### Hook Categories
|
||||
|
||||
| Category | Purpose | When Called |
|
||||
|----------|---------|-------------|
|
||||
| `convertToLlm` | Filter/transform messages | Before LLM call |
|
||||
| `transformContext` | Manipulate context | Before LLM call |
|
||||
| `beforeToolCall` | Block tool execution | Before tool runs |
|
||||
| `afterToolCall` | Override tool results | After tool runs |
|
||||
| `shouldStopAfterTurn` | Request early stop | After turn ends |
|
||||
| `prepareNextTurn` | Update config | Before next turn |
|
||||
| `getSteeringMessages` | Interrupt agent | After turn ends |
|
||||
| `getFollowUpMessages` | Queue messages | When agent stops |
|
||||
|
||||
### Hook Flow
|
||||
|
||||
```
|
||||
Agent.prompt("Build app")
|
||||
│
|
||||
├─► transformContext() [hook]
|
||||
│
|
||||
├─► convertToLlm() [hook]
|
||||
│
|
||||
├─► LLM call
|
||||
│
|
||||
├─► executeToolCalls()
|
||||
│ ├─► beforeToolCall() [hook]
|
||||
│ ├─► tool.execute()
|
||||
│ └─► afterToolCall() [hook]
|
||||
│
|
||||
└─► turn_end
|
||||
├─► shouldStopAfterTurn() [hook]
|
||||
├─► prepareNextTurn() [hook]
|
||||
├─► getSteeringMessages() [hook]
|
||||
└─► getFollowUpMessages() [hook]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: AgentHarness (45 minutes)
|
||||
|
||||
### High-Level API
|
||||
|
||||
```typescript
|
||||
// Create harness
|
||||
const harness = new AgentHarness({
|
||||
session: session,
|
||||
models: models,
|
||||
tools: [weatherTool, gitTool],
|
||||
activeToolNames: ["weather", "git"],
|
||||
model: gpt4Model,
|
||||
thinkingLevel: "medium"
|
||||
});
|
||||
|
||||
// Main operations
|
||||
await harness.prompt("What's the weather in London?");
|
||||
|
||||
// Queue management
|
||||
await harness.steer("Wait, check this first"); // Interrupt
|
||||
await harness.followUp("Now summarize"); // After agent stops
|
||||
await harness.nextTurn("Also deploy"); // Next turn
|
||||
|
||||
// Session management
|
||||
await harness.compact(); // Compress context
|
||||
await harness.navigateTree(entryId); // Branch conversation
|
||||
```
|
||||
|
||||
### Session Tree
|
||||
|
||||
```
|
||||
Session = Conversation History as a Tree
|
||||
|
||||
root
|
||||
├─ message [user prompt #1]
|
||||
│ └─ message [assistant #1]
|
||||
│ └─ tool_result [result]
|
||||
│ └─ message [user prompt #2]
|
||||
│ └─ compaction [summary]
|
||||
│ ├─ retained: [recent messages]
|
||||
│ └─ message [assistant continues]
|
||||
│ └─ leaf [current head]
|
||||
│
|
||||
└─ branch_summary [point where branch created]
|
||||
└─ message [new branch]
|
||||
└─ leaf [new head]
|
||||
```
|
||||
|
||||
**Key Operations**:
|
||||
- `buildContext()` → Get LLM context from tree
|
||||
- `fork()` → Create branch at point
|
||||
- `compact()` → Summarize history
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Tool Execution (30 minutes)
|
||||
|
||||
### Tool Lifecycle
|
||||
|
||||
```
|
||||
1. LLM sends tool call
|
||||
└─► AssistantMessage with toolCall block
|
||||
|
||||
2. prepareToolCall()
|
||||
├─► Find tool by name
|
||||
├─► Validate arguments
|
||||
└─► beforeToolCall() hook
|
||||
|
||||
3. executePreparedToolCall()
|
||||
└─► tool.execute() with onUpdate callback
|
||||
|
||||
4. finalizeExecutedToolCall()
|
||||
└─► afterToolCall() hook
|
||||
|
||||
5. Emit events
|
||||
├─► tool_execution_start
|
||||
├─► tool_execution_update (streaming)
|
||||
└─► tool_execution_end
|
||||
```
|
||||
|
||||
### Tool Definition
|
||||
|
||||
```typescript
|
||||
const weatherTool: AgentTool = {
|
||||
name: "get_weather",
|
||||
label: "Get Weather",
|
||||
description: "Get current weather for a city",
|
||||
parameters: Type.Object({ city: Type.String() }),
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
// Check for abort
|
||||
if (signal?.aborted) throw new Error("Aborted");
|
||||
|
||||
// Long operation with streaming
|
||||
const result = await fetchWeather(params.city);
|
||||
onUpdate({ content: [{ type: "text", text: "Fetching..." }] });
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: result }],
|
||||
details: { city: params.city, temp: result.temp },
|
||||
usage: { input: 0, output: 0, ... }
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Session Persistence (30 minutes)
|
||||
|
||||
### Entry Types
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `message` | User/assistant/toolResult |
|
||||
| `model_change` | Model switch |
|
||||
| `thinking_level_change` | Reasoning level |
|
||||
| `active_tools_change` | Tools change |
|
||||
| `compaction` | History summary |
|
||||
| `branch_summary` | Branch point |
|
||||
| `custom` | App data (not visible to model) |
|
||||
| `custom_message` | Custom message |
|
||||
| `label` | User label |
|
||||
| `leaf` | Current head |
|
||||
|
||||
### Context Building
|
||||
|
||||
```typescript
|
||||
// 1. Get path from leaf to root
|
||||
const pathEntries = await session.getBranch();
|
||||
|
||||
// 2. Apply transforms (compaction)
|
||||
const contextEntries = defaultContextEntryTransform(pathEntries);
|
||||
|
||||
// 3. Project entries to messages
|
||||
const messages = contextEntries.flatMap(sessionEntryToContextMessages);
|
||||
|
||||
// 4. Derive state (model, thinking level, active tools)
|
||||
const state = deriveSessionContextState(pathEntries);
|
||||
|
||||
// 5. Return context
|
||||
return { ...state, messages };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### What to Remember
|
||||
|
||||
1. **Two layers**: Agent (core) + AgentHarness (high-level)
|
||||
2. **Messages everywhere**: Input, output, tools, events
|
||||
3. **Hooks for customization**: Transform messages, block tools, override results
|
||||
4. **Session = Tree**: Persistent conversation history with branching
|
||||
5. **Events for UI**: All state changes emitted as events
|
||||
6. **Tool lifecycle**: Prepare → Execute → Finalize → Emit
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Read the detailed docs**:
|
||||
- `01-ARCHITECTURE-OVERVIEW.md` - Big picture
|
||||
- `02-AGENT-LOOP-DETAILED.md` - Core loop
|
||||
- `03-HOOK-SYSTEM.md` - Hooks reference
|
||||
- `04-SESSION-ARCHITECTURE.md` - Session system
|
||||
- `05-TOOL-EXECUTION.md` - Tool system
|
||||
- `06-AGENTHARNESS-REFERENCE.md` - API reference
|
||||
- `07-DATA-FLOW-STATE.md` - Data flow
|
||||
- `08-LEARNING-PATH.md` - Study guide
|
||||
- `09-DIAGRAMS.md` - Visual diagrams
|
||||
|
||||
2. **Design your Julia implementation**:
|
||||
- Data types
|
||||
- Core agent loop
|
||||
- Hook system
|
||||
- Session persistence
|
||||
- Tool execution
|
||||
|
||||
3. **Start coding**:
|
||||
- Implement basic types
|
||||
- Implement core loop
|
||||
- Add hooks
|
||||
- Add session
|
||||
- Add harness
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**Context window management**:
|
||||
```typescript
|
||||
transformContext: async (messages) => {
|
||||
if (estimateTokens(messages) > MAX_TOKENS) {
|
||||
return pruneOldMessages(messages);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
```
|
||||
|
||||
**Tool permission checks**:
|
||||
```typescript
|
||||
beforeToolCall: async ({ toolCall }) => {
|
||||
if (toolCall.name === "bash" && !await canExecute()) {
|
||||
return { block: true, reason: "Permission denied" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
**Streaming updates**:
|
||||
```typescript
|
||||
execute: async (id, params, signal, onUpdate) => {
|
||||
for await (const chunk of process()) {
|
||||
onUpdate({ content: [{ type: "text", text: `Progress: ${chunk}%` }] });
|
||||
}
|
||||
return finalResult;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Agent Core (agent.ts, agent-loop.ts)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `runAgentLoop()` | Start new conversation |
|
||||
| `runAgentLoopContinue()` | Continue existing |
|
||||
| `runLoop()` | Main iteration |
|
||||
| `streamAssistantResponse()` | Stream LLM |
|
||||
| `executeToolCalls()` | Execute tools |
|
||||
|
||||
### AgentHarness API
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `prompt()` | Run conversation |
|
||||
| `steer()` | Interrupt agent |
|
||||
| `followUp()` | Queue message |
|
||||
| `compact()` | Compress context |
|
||||
| `navigateTree()` | Branch conversation |
|
||||
|
||||
### Hook Types
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `convertToLlm` | Convert messages |
|
||||
| `beforeToolCall` | Block tools |
|
||||
| `afterToolCall` | Override results |
|
||||
| `shouldStopAfterTurn` | Request stop |
|
||||
|
||||
### Entry Types
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `message` | Conversation messages |
|
||||
| `compaction` | History summary |
|
||||
| `branch_summary` | Branch point |
|
||||
|
||||
---
|
||||
|
||||
**You now have the foundation to reimplement the agent in Julia!**
|
||||
|
||||
Start with data types and the core loop, then add hooks, session, and harness layers incrementally.
|
||||
@@ -0,0 +1,596 @@
|
||||
# Pi Agent Architecture - Complete Summary
|
||||
|
||||
## Quick Reference for Julia Reimplementation
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Architecture (Top-Down)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION LAYER │
|
||||
│ • Agent (Low-level) │
|
||||
│ • AgentHarness (High-level) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Agent Core │ │ Session System │ │ Tool Execution │
|
||||
│ • Async loop │ │ • Tree storage │ │ • Prepare │
|
||||
│ • Event │ │ • Branching │ │ • Execute │
|
||||
│ • Message │ │ • Compaction │ │ • Finalize │
|
||||
│ • Hooks │ │ • Context │ │ • Streaming │
|
||||
└───────────────┘ └──────────────────┘ └──────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ LLM PROVIDER LAYER │
|
||||
│ • StreamFn (streaming interface) │
|
||||
│ • Models (LLM catalog) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Key Components
|
||||
|
||||
### Agent Core
|
||||
|
||||
**Files**: `src/agent.ts`, `src/agent-loop.ts`, `src/types.ts`
|
||||
|
||||
**Responsibilities**:
|
||||
- State management (messages, tools, isStreaming, pendingToolCalls)
|
||||
- Event streaming (agent_start, turn_start, message_start, etc.)
|
||||
- Queue management (steering, follow-up)
|
||||
- Hook execution (beforeToolCall, afterToolCall, etc.)
|
||||
|
||||
**Key Types**:
|
||||
```typescript
|
||||
type AgentMessage = Message | CustomAgentMessages
|
||||
type AgentEvent =
|
||||
| { type: "agent_start" }
|
||||
| { type: "agent_end"; messages: AgentMessage[] }
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
|
||||
| { type: "message_start"; message: AgentMessage }
|
||||
| { type: "message_update"; message: AgentMessage }
|
||||
| { type: "message_end"; message: AgentMessage }
|
||||
| { type: "tool_execution_start"; ... }
|
||||
| { type: "tool_execution_end"; ... }
|
||||
|
||||
interface AgentContext {
|
||||
systemPrompt: string
|
||||
messages: AgentMessage[]
|
||||
tools?: AgentTool<any>[]
|
||||
}
|
||||
```
|
||||
|
||||
### AgentHarness
|
||||
|
||||
**Files**: `src/harness/agent-harness.ts`
|
||||
|
||||
**Responsibilities**:
|
||||
- Session persistence (JSONL/Memory)
|
||||
- Branching (create conversation paths)
|
||||
- Compaction (summarize history)
|
||||
- Tool context binding
|
||||
- Hook system (before_agent_start, tool_call, tool_result, etc.)
|
||||
- Queue management (steer, followUp, nextTurn)
|
||||
|
||||
**Key Types**:
|
||||
```typescript
|
||||
interface AgentHarnessEvent<TSkill, TPromptTemplate> =
|
||||
| { type: "agent_start" } // From core
|
||||
| { type: "before_agent_start" } // Harness-specific
|
||||
| { type: "tool_call"; ... }
|
||||
| { type: "tool_result"; ... }
|
||||
| { type: "session_before_compact"; ... }
|
||||
| { type: "session_before_tree"; ... }
|
||||
// ... more harness events
|
||||
|
||||
interface SessionContext {
|
||||
systemPrompt: string
|
||||
messages: AgentMessage[]
|
||||
thinkingLevel: ThinkingLevel
|
||||
model: { provider: string; modelId: string } | null
|
||||
activeToolNames: string[] | null
|
||||
}
|
||||
```
|
||||
|
||||
### Session System
|
||||
|
||||
**Files**: `src/harness/session/`
|
||||
|
||||
**Responsibilities**:
|
||||
- Conversation persistence as tree
|
||||
- Context building from tree
|
||||
- Branching and forking
|
||||
- Compaction
|
||||
- Entry types (message, model_change, compaction, branch_summary, etc.)
|
||||
|
||||
**Key Types**:
|
||||
```typescript
|
||||
interface SessionTreeEntry {
|
||||
id: string
|
||||
parentId: string | null
|
||||
timestamp: string
|
||||
type: string // "message", "compaction", "branch_summary", etc.
|
||||
}
|
||||
|
||||
interface CompactionEntry extends SessionTreeEntry {
|
||||
type: "compaction"
|
||||
summary: string
|
||||
firstKeptEntryId?: string
|
||||
tokensBefore: number
|
||||
retainedTail?: AgentMessage[]
|
||||
}
|
||||
```
|
||||
|
||||
### Tool System
|
||||
|
||||
**Files**: `src/harness/tools/`
|
||||
|
||||
**Responsibilities**:
|
||||
- Tool definition and execution
|
||||
- Sequential vs parallel execution
|
||||
- Streaming updates
|
||||
- Error handling
|
||||
- Before/after hooks
|
||||
|
||||
**Key Types**:
|
||||
```typescript
|
||||
interface AgentTool<TParameters, TDetails> extends Tool<TParameters> {
|
||||
label: string
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>
|
||||
): Promise<AgentToolResult<TDetails>>
|
||||
}
|
||||
|
||||
interface AgentToolResult<T> {
|
||||
content: (TextContent | ImageContent)[]
|
||||
details: T
|
||||
usage?: Usage
|
||||
addedToolNames?: string[]
|
||||
terminate?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Message Flow
|
||||
|
||||
```
|
||||
User Input
|
||||
│
|
||||
├─► Agent.prompt("Hello")
|
||||
│ └─► normalizePromptInput() → AgentMessage[]
|
||||
│
|
||||
└─► runWithLifecycle()
|
||||
├─► isStreaming = true
|
||||
└─► runAgentLoop()
|
||||
│
|
||||
├─► agent_start
|
||||
├─► turn_start
|
||||
├─► message_start/end (user)
|
||||
│
|
||||
├─► streamAssistantResponse()
|
||||
│ ├─► transformContext() [optional]
|
||||
│ ├─► convertToLlm()
|
||||
│ └─► streamFn() → LLM
|
||||
│
|
||||
├─► executeToolCalls()
|
||||
│ ├─► prepareToolCall()
|
||||
│ │ ├─► Find tool
|
||||
│ │ ├─► Validate args
|
||||
│ │ └─► beforeToolCall() [hook]
|
||||
│ │
|
||||
│ ├─► executePreparedToolCall()
|
||||
│ │ └─► tool.execute() with onUpdate
|
||||
│ │
|
||||
│ └─► finalizeExecutedToolCall()
|
||||
│ └─► afterToolCall() [hook]
|
||||
│
|
||||
└─► turn_end
|
||||
├─► prepareNextTurn() [hook]
|
||||
├─► shouldStopAfterTurn() [hook]
|
||||
├─► Drain steering queue
|
||||
└─► Drain follow-up queue
|
||||
|
||||
┌─► Continue? → Repeat
|
||||
└─► Stop? → agent_end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Hook System
|
||||
|
||||
| Hook | Layer | When | Can Block? | Use Case |
|
||||
|------|-------|------|------------|----------|
|
||||
| `convertToLlm` | Agent | Before LLM | No | Filter messages |
|
||||
| `transformContext` | Agent | Before LLM | Yes | Prune context |
|
||||
| `beforeToolCall` | Agent | Before tool | Yes | Permission checks |
|
||||
| `afterToolCall` | Agent | After tool | Yes | Override results |
|
||||
| `shouldStopAfterTurn` | Agent | After turn | Yes | Request early stop |
|
||||
| `prepareNextTurn` | Agent | Before next | Yes | Update config |
|
||||
| `getSteeringMessages` | Agent | After turn | Yes | Interrupt agent |
|
||||
| `getFollowUpMessages` | Agent | When stop | Yes | Queue messages |
|
||||
|
||||
**Harness Hooks**:
|
||||
- `before_agent_start` - Modify system prompt
|
||||
- `context` - Transform context
|
||||
- `tool_call` - Log/before tool
|
||||
- `tool_result` - Log/after tool
|
||||
- `session_before_compact` - Customize compaction
|
||||
- `session_before_tree` - Customize branching
|
||||
- `before_provider_request` - Modify stream options
|
||||
- `before_provider_payload` - Modify LLM payload
|
||||
|
||||
---
|
||||
|
||||
## 5. Session Tree
|
||||
|
||||
```
|
||||
root (parentId: null)
|
||||
├─ message [id: 1] ← User prompt
|
||||
│ └─ message [id: 2] ← Assistant
|
||||
│ └─ tool_result [id: 3]
|
||||
│ └─ message [id: 4]
|
||||
│ └─ compaction [id: 5]
|
||||
│ ├─ summary: "..."
|
||||
│ ├─ firstKeptEntryId: msg6.id
|
||||
│ ├─ tokensBefore: 10000
|
||||
│ ├─ retainedTail: [msg6, msg7]
|
||||
│ └─ msg6 [id: 6] ← Retained
|
||||
│ └─ ... (rest of retained)
|
||||
│ └─ leaf [id: 8] ← Current head
|
||||
│
|
||||
└─ branch_summary [id: 9] ← Branch point
|
||||
└─ message [id: 10] ← New branch
|
||||
└─ leaf [id: 11] ← New head
|
||||
```
|
||||
|
||||
**Key Operations**:
|
||||
- `getBranch()` → Get entries from leaf to root
|
||||
- `buildContext()` → Project entries to messages
|
||||
- `fork()` → Create branch at entry
|
||||
- `compact()` → Summarize history
|
||||
|
||||
---
|
||||
|
||||
## 6. Tool Execution Flow
|
||||
|
||||
```
|
||||
1. LLM sends tool call
|
||||
└─► AssistantMessage with toolCall block
|
||||
|
||||
2. prepareToolCall()
|
||||
├─► Find tool
|
||||
├─► prepareArguments() [optional]
|
||||
├─► validateToolArguments()
|
||||
└─► beforeToolCall() [hook]
|
||||
├─► block: true → Error
|
||||
└─► block: undefined → Continue
|
||||
|
||||
3. executePreparedToolCall()
|
||||
└─► tool.execute(toolCallId, params, signal, onUpdate)
|
||||
├─► onUpdate(partialResult) [streaming]
|
||||
└─► Return: { content, details, ... }
|
||||
|
||||
4. finalizeExecutedToolCall()
|
||||
└─► afterToolCall() [hook]
|
||||
├─► Override: content, details, isError, usage, terminate
|
||||
└─► Use executed result
|
||||
|
||||
5. Emit events
|
||||
├─► tool_execution_start
|
||||
├─► tool_execution_update [streaming]
|
||||
└─► tool_execution_end
|
||||
│
|
||||
└─► createToolResultMessage()
|
||||
└─► Emit: message_start/end (toolResult)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Types
|
||||
|
||||
### Messages
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
role: "user" | "assistant" | "toolResult"
|
||||
content: (TextContent | ImageContent)[]
|
||||
api?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
usage?: Usage
|
||||
stopReason?: StopReason
|
||||
errorMessage?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface TextContent { type: "text"; text: string }
|
||||
interface ImageContent { type: "image"; mediaType: string; data: string }
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
```typescript
|
||||
type AgentEvent =
|
||||
| { type: "agent_start" }
|
||||
| { type: "agent_end"; messages: AgentMessage[] }
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
|
||||
| { type: "message_start"; message: AgentMessage }
|
||||
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||
| { type: "message_end"; message: AgentMessage }
|
||||
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
|
||||
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
|
||||
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean }
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
```typescript
|
||||
interface AgentTool<TParameters, TDetails> extends Tool<TParameters> {
|
||||
label: string
|
||||
prepareArguments?: (args: unknown) => Static<TParameters>
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>
|
||||
): Promise<AgentToolResult<TDetails>>
|
||||
}
|
||||
|
||||
interface AgentToolResult<T> {
|
||||
content: (TextContent | ImageContent)[]
|
||||
details: T
|
||||
usage?: Usage
|
||||
addedToolNames?: string[]
|
||||
terminate?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. State Management
|
||||
|
||||
### Agent State
|
||||
|
||||
```typescript
|
||||
interface AgentState {
|
||||
systemPrompt: string
|
||||
model: Model<any>
|
||||
thinkingLevel: ThinkingLevel
|
||||
tools: AgentTool<any>[]
|
||||
messages: AgentMessage[]
|
||||
isStreaming: boolean
|
||||
streamingMessage?: AgentMessage
|
||||
pendingToolCalls: Set<string>
|
||||
errorMessage?: string
|
||||
}
|
||||
```
|
||||
|
||||
### State Mutations
|
||||
|
||||
| Event | State Change |
|
||||
|-------|-------------|
|
||||
| `message_start` | `streamingMessage = message` |
|
||||
| `message_update` | `streamingMessage = message` |
|
||||
| `message_end` | `messages.push(message)`, `streamingMessage = undefined` |
|
||||
| `tool_execution_start` | `pendingToolCalls.add(toolCallId)` |
|
||||
| `tool_execution_end` | `pendingToolCalls.delete(toolCallId)` |
|
||||
| `turn_end` | `errorMessage = message.errorMessage` (if error) |
|
||||
| `agent_end` | `streamingMessage = undefined` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Queue System
|
||||
|
||||
### Steering Queue
|
||||
|
||||
**Purpose**: Interrupt agent while working
|
||||
|
||||
**Mode**: `"all"` or `"one-at-a-time"`
|
||||
|
||||
**Flow**: After turn ends → Drain → Inject into context → Next LLM call
|
||||
|
||||
### Follow-up Queue
|
||||
|
||||
**Purpose**: Queue messages for after agent stops
|
||||
|
||||
**Mode**: `"all"` or `"one-at-a-time"`
|
||||
|
||||
**Flow**: When agent would stop → Drain → Set as pending → Continue loop
|
||||
|
||||
---
|
||||
|
||||
## 10. Entry Types
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `message` | User/assistant/toolResult messages |
|
||||
| `model_change` | Model switch (`setModel()`) |
|
||||
| `thinking_level_change` | Reasoning level (`setThinkingLevel()`) |
|
||||
| `active_tools_change` | Tools change (`setActiveTools()`) |
|
||||
| `compaction` | History summary (`compact()`) |
|
||||
| `branch_summary` | Branch point (branching) |
|
||||
| `custom` | App data (not visible to model) |
|
||||
| `custom_message` | Custom message |
|
||||
| `label` | User-assigned label |
|
||||
| `leaf` | Current session head |
|
||||
|
||||
---
|
||||
|
||||
## 11. Common Patterns
|
||||
|
||||
### Context Window Management
|
||||
|
||||
```typescript
|
||||
transformContext: async (messages, signal) => {
|
||||
if (estimateTokens(messages) > MAX_TOKENS) {
|
||||
return pruneOldestMessages(messages, Math.floor(MAX_TOKENS * 0.3))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Permission Checks
|
||||
|
||||
```typescript
|
||||
beforeToolCall: async ({ toolCall, args }, signal) => {
|
||||
if (toolCall.name === "bash" && signal?.aborted) {
|
||||
return { block: true, reason: "Operation aborted" }
|
||||
}
|
||||
if (toolCall.name === "bash" && !await canExecute(args)) {
|
||||
return { block: true, reason: "Permission denied" }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Updates
|
||||
|
||||
```typescript
|
||||
execute: async (id, params, signal, onUpdate) => {
|
||||
for await (const item of longProcess()) {
|
||||
if (signal?.aborted) throw new Error("Aborted")
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: `Progress: ${item}%` }],
|
||||
details: { progress: item }
|
||||
})
|
||||
}
|
||||
return finalResult
|
||||
}
|
||||
```
|
||||
|
||||
### Early Termination
|
||||
|
||||
```typescript
|
||||
shouldStopAfterTurn: async ({ message, toolResults }) => {
|
||||
// Check if model indicates completion
|
||||
if (message.content.some(c => c.text?.includes("TASK_COMPLETE"))) {
|
||||
return true
|
||||
}
|
||||
// Stop if all tool calls set terminate
|
||||
return toolResults.every(r => r.terminate)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Learning Path
|
||||
|
||||
1. **Start with types** - Understand AgentMessage, AgentEvent, AgentTool
|
||||
2. **Study agent-loop** - See how messages flow through the loop
|
||||
3. **Read hooks** - Understand customization points
|
||||
4. **Explore session** - See persistence and tree structure
|
||||
5. **Study tools** - Understand tool execution
|
||||
6. **Read harness** - See high-level API
|
||||
7. **Design in Julia** - Implement step by step
|
||||
|
||||
---
|
||||
|
||||
## 13. Implementation Checklist
|
||||
|
||||
### Phase 1: Data Types (Julia)
|
||||
- [ ] AgentMessage equivalent
|
||||
- [ ] AgentEvent types
|
||||
- [ ] AgentTool interface
|
||||
- [ ] AgentContext
|
||||
|
||||
### Phase 2: Core Agent
|
||||
- [ ] Agent class with state
|
||||
- [ ] Event streaming
|
||||
- [ ] Message queue (steering, follow-up)
|
||||
|
||||
### Phase 3: Agent Loop
|
||||
- [ ] runAgentLoop()
|
||||
- [ ] streamAssistantResponse()
|
||||
- [ ] executeToolCalls()
|
||||
- [ ] Tool preparation and execution
|
||||
- [ ] Event emission
|
||||
|
||||
### Phase 4: Hooks
|
||||
- [ ] Hook registration
|
||||
- [ ] Hook execution
|
||||
- [ ] Return value handling
|
||||
|
||||
### Phase 5: Session
|
||||
- [ ] SessionTreeEntry types
|
||||
- [ ] Tree structure
|
||||
- [ ] Context building
|
||||
- [ ] Persistence
|
||||
|
||||
### Phase 6: AgentHarness
|
||||
- [ ] High-level API
|
||||
- [ ] Queue management
|
||||
- [ ] Branching
|
||||
- [ ] Compaction
|
||||
|
||||
---
|
||||
|
||||
## 14. Quick Reference Cards
|
||||
|
||||
### Agent Core
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `runAgentLoop()` | Start new conversation |
|
||||
| `runAgentLoopContinue()` | Continue existing |
|
||||
| `streamAssistantResponse()` | Stream LLM |
|
||||
| `executeToolCalls()` | Execute tools |
|
||||
| `prepareToolCall()` | Prepare tool execution |
|
||||
| `executePreparedToolCall()` | Execute tool |
|
||||
| `finalizeExecutedToolCall()` | Finalize tool |
|
||||
|
||||
### AgentHarness
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `prompt()` | Run conversation |
|
||||
| `skill()` | Execute skill |
|
||||
| `promptFromTemplate()` | Run template |
|
||||
| `steer()` | Interrupt agent |
|
||||
| `followUp()` | Queue message |
|
||||
| `nextTurn()` | Queue next turn |
|
||||
| `compact()` | Compress context |
|
||||
| `navigateTree()` | Branch conversation |
|
||||
| `setModel()` | Change model |
|
||||
| `setThinkingLevel()` | Change reasoning |
|
||||
| `setTools()` | Set tools |
|
||||
| `setActiveTools()` | Set active tools |
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | Layer | Purpose |
|
||||
|------|-------|---------|
|
||||
| `convertToLlm` | Agent | Convert messages |
|
||||
| `transformContext` | Agent | Manipulate context |
|
||||
| `beforeToolCall` | Agent | Block tools |
|
||||
| `afterToolCall` | Agent | Override results |
|
||||
| `shouldStopAfterTurn` | Agent | Request stop |
|
||||
| `prepareNextTurn` | Agent | Update config |
|
||||
| `getSteeringMessages` | Agent | Interrupt |
|
||||
| `getFollowUpMessages` | Agent | Queue messages |
|
||||
|
||||
### Session
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `buildContext()` | Get LLM context |
|
||||
| `appendMessage()` | Add message |
|
||||
| `fork()` | Create branch |
|
||||
| `compact()` | Compress history |
|
||||
|
||||
---
|
||||
|
||||
**You now have a complete reference for reimplementing the Pi Agent in Julia!**
|
||||
|
||||
Start with the data types, implement the core loop, add hooks, then build up to the harness and session layers.
|
||||
@@ -0,0 +1,270 @@
|
||||
# Pi Agent Learning Resources
|
||||
|
||||
This folder contains comprehensive learning materials for understanding the Pi Agent architecture.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation Files
|
||||
|
||||
| File | Description | Time |
|
||||
|------|-------------|------|
|
||||
| **00-README.md** | This file | 5 min |
|
||||
| **01-ARCHITECTURE-OVERVIEW.md** | Top-down architecture overview with diagrams | 30 min |
|
||||
| **02-AGENT-LOOP-DETAILED.md** | Core agent loop implementation details | 45 min |
|
||||
| **03-HOOK-SYSTEM.md** | Complete hook system reference | 45 min |
|
||||
| **04-SESSION-ARCHITECTURE.md** | Session persistence and tree structure | 45 min |
|
||||
| **05-TOOL-EXECUTION.md** | Tool execution mechanics | 45 min |
|
||||
| **06-AGENTHARNESS-REFERENCE.md** | High-level API reference | 45 min |
|
||||
| **07-DATA-FLOW-STATE.md** | Data flow and state management | 45 min |
|
||||
| **08-LEARNING-PATH.md** | Step-by-step learning guide | 2 hrs |
|
||||
| **09-DIAGRAMS.md** | Visual diagrams and flowcharts | 30 min |
|
||||
| **10-QUICK-START.md** | Quick start guide for Julia reimplementation | 30 min |
|
||||
| **11-COMPLETE-SUMMARY.md** | Complete reference summary | 20 min |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Learning Paths
|
||||
|
||||
### Path 1: Fast Track (4-5 hours)
|
||||
|
||||
**Goal**: Understand enough to start implementing
|
||||
|
||||
1. **01-ARCHITECTURE-OVERVIEW.md** - Big picture
|
||||
2. **10-QUICK-START.md** - Quick start guide
|
||||
3. **02-AGENT-LOOP-DETAILED.md** - Core loop (skim)
|
||||
4. **11-COMPLETE-SUMMARY.md** - Reference
|
||||
|
||||
**Then**: Start implementing in Julia
|
||||
|
||||
### Path 2: Thorough (10-12 hours)
|
||||
|
||||
**Goal**: Deep understanding before implementation
|
||||
|
||||
1. **01-ARCHITECTURE-OVERVIEW.md** - 30 min
|
||||
2. **02-AGENT-LOOP-DETAILED.md** - 45 min
|
||||
3. **03-HOOK-SYSTEM.md** - 45 min
|
||||
4. **04-SESSION-ARCHITECTURE.md** - 45 min
|
||||
5. **05-TOOL-EXECUTION.md** - 45 min
|
||||
6. **06-AGENTHARNESS-REFERENCE.md** - 45 min
|
||||
7. **07-DATA-FLOW-STATE.md** - 45 min
|
||||
8. **09-DIAGRAMS.md** - Reference throughout
|
||||
|
||||
**Then**: Follow **08-LEARNING-PATH.md** for implementation
|
||||
|
||||
### Path 3: Comprehensive (15-20 hours)
|
||||
|
||||
**Goal**: Master the entire system
|
||||
|
||||
1. **01-ARCHITECTURE-OVERVIEW.md** - 30 min
|
||||
2. **02-AGENT-LOOP-DETAILED.md** - 2 hrs
|
||||
3. **03-HOOK-SYSTEM.md** - 2 hrs
|
||||
4. **04-SESSION-ARCHITECTURE.md** - 2 hrs
|
||||
5. **05-TOOL-EXECUTION.md** - 2 hrs
|
||||
6. **06-AGENTHARNESS-REFERENCE.md** - 2 hrs
|
||||
7. **07-DATA-FLOW-STATE.md** - 2 hrs
|
||||
8. **08-LEARNING-PATH.md** - Follow implementation guide
|
||||
9. **Read source files** - `src/agent.ts`, `src/agent-loop.ts`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION LAYER │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ Agent │ │ AgentHarness │ │ Your Custom App │ │
|
||||
│ │ (Core) │ │ (High-Level) │ │ │ │
|
||||
│ └───────┬──────┘ └────────┬─────────┘ └───────────┬──────────────┘ │
|
||||
└──────────┼─────────────────────┼──────────────────────────┼──────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────────┐ ┌─────────────────────┐ ┌───────────────────────────┐
|
||||
│ Agent Core │ │ Session System │ │ Tool System │
|
||||
│ • agent-loop.ts │ │ • session/ │ │ • tools/ │
|
||||
│ • agent.ts │ │ • compaction/ │ │ • bash.ts │
|
||||
│ • types.ts │ │ • session.ts │ │ • read.ts │
|
||||
└─────────┬─────────┘ └──────────┬──────────┘ │ • write.ts │
|
||||
│ │ │ • edit.ts │
|
||||
▼ ▼ └──────────┬──────────────┘
|
||||
┌───────────────────────────────────────────────────────────┼──────────────────┐
|
||||
│ AGENT CORE LAYER │ │
|
||||
│ • Async iteration │ │
|
||||
│ • Event streaming │ │
|
||||
│ • Hook execution │ │
|
||||
│ • Tool execution │ │
|
||||
└────────────────────────────────────────────────────────────┴──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Key Concepts
|
||||
|
||||
### 1. Agent Core
|
||||
|
||||
- **Low-level** async iteration
|
||||
- **Message-based** communication
|
||||
- **Event-driven** state changes
|
||||
- **Hook system** for customization
|
||||
|
||||
### 2. AgentHarness
|
||||
|
||||
- **High-level** API
|
||||
- **Session persistence** (tree structure)
|
||||
- **Branching** support
|
||||
- **Context compaction**
|
||||
- **Tool context binding**
|
||||
|
||||
### 3. Hooks
|
||||
|
||||
- **Before/after** tool execution
|
||||
- **Message transformation**
|
||||
- **Context manipulation**
|
||||
- **Queue draining**
|
||||
|
||||
### 4. Session Tree
|
||||
|
||||
- **Persistent** conversation history
|
||||
- **Branchable** conversation paths
|
||||
- **Context building** from tree
|
||||
- **Compaction** for efficiency
|
||||
|
||||
---
|
||||
|
||||
## 🎓 How to Use This Guide
|
||||
|
||||
### For Top-Down Learning
|
||||
|
||||
1. Start with **01-ARCHITECTURE-OVERVIEW.md**
|
||||
2. Study **09-DIAGRAMS.md** for visual understanding
|
||||
3. Read **02-AGENT-LOOP-DETAILED.md** for core implementation
|
||||
4. Explore **03-HOOK-SYSTEM.md** for customization
|
||||
5. Understand **04-SESSION-ARCHITECTURE.md** for persistence
|
||||
|
||||
### For Quick Start
|
||||
|
||||
1. Read **10-QUICK-START.md**
|
||||
2. Use **11-COMPLETE-SUMMARY.md** as reference
|
||||
3. Implement while referencing other docs
|
||||
|
||||
### For Deep Dive
|
||||
|
||||
1. Follow the learning path in **08-LEARNING-PATH.md**
|
||||
2. Read source files alongside documentation
|
||||
3. Implement incrementally
|
||||
4. Test each component
|
||||
|
||||
---
|
||||
|
||||
## 📝 Implementation Checklist
|
||||
|
||||
### Phase 1: Data Types (Julia)
|
||||
- [ ] AgentMessage type
|
||||
- [ ] AgentEvent types
|
||||
- [ ] AgentTool interface
|
||||
- [ ] AgentContext
|
||||
- [ ] AgentState
|
||||
|
||||
### Phase 2: Core Agent
|
||||
- [ ] Agent class
|
||||
- [ ] State management
|
||||
- [ ] Event streaming
|
||||
- [ ] Queue management
|
||||
|
||||
### Phase 3: Agent Loop
|
||||
- [ ] `runAgentLoop()`
|
||||
- [ ] `streamAssistantResponse()`
|
||||
- [ ] `executeToolCalls()`
|
||||
- [ ] `prepareToolCall()`
|
||||
- [ ] Event emission
|
||||
|
||||
### Phase 4: Hooks
|
||||
- [ ] Hook registration
|
||||
- [ ] Hook execution
|
||||
- [ ] Return value handling
|
||||
|
||||
### Phase 5: Session
|
||||
- [ ] Tree structure
|
||||
- [ ] Entry types
|
||||
- [ ] Context building
|
||||
- [ ] Persistence
|
||||
|
||||
### Phase 6: AgentHarness
|
||||
- [ ] High-level API
|
||||
- [ ] Queue methods
|
||||
- [ ] Branching
|
||||
- [ ] Compaction
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Recommended Implementation Order
|
||||
|
||||
1. **Data Types** - Define all types in Julia
|
||||
2. **Core Agent** - Implement Agent class with basic state
|
||||
3. **Event System** - Implement event streaming
|
||||
4. **Agent Loop** - Implement the main loop
|
||||
5. **Tool System** - Implement tool execution
|
||||
6. **Hooks** - Add hook system
|
||||
7. **Session** - Implement session persistence
|
||||
8. **Harness** - Add high-level API
|
||||
|
||||
---
|
||||
|
||||
## 📚 Related Files
|
||||
|
||||
- `packages/agent/src/` - Source files
|
||||
- `agent.ts` - Agent class
|
||||
- `agent-loop.ts` - Core loop
|
||||
- `types.ts` - Type definitions
|
||||
- `proxy.ts` - Proxy utilities
|
||||
- `stream-fn.ts` - Default stream function
|
||||
- `harness/` - Harness implementation
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
### For Julia Implementation
|
||||
|
||||
1. **Start simple** - Implement basic types first
|
||||
2. **Test incrementally** - Test each component
|
||||
3. **Follow patterns** - Use Julia's type system
|
||||
4. **Use idioms** - Follow Julia conventions
|
||||
5. **Refer to docs** - Use this guide as reference
|
||||
|
||||
### Common Patterns
|
||||
|
||||
- **Event-driven** - Use Julia's event system
|
||||
- **Immutable data** - Prefer immutable structures
|
||||
- **Multiple dispatch** - Leverage Julia's dispatch
|
||||
- **Async/await** - Use Julia's async for streaming
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Criteria
|
||||
|
||||
After learning, you should be able to:
|
||||
|
||||
✅ Explain the two-layer architecture
|
||||
✅ Trace a message through the system
|
||||
✅ Identify when each hook is called
|
||||
✅ Explain how session persistence works
|
||||
✅ Describe the tool execution flow
|
||||
✅ Implement a custom tool
|
||||
✅ Create a conversation branch
|
||||
✅ Compress conversation history
|
||||
|
||||
---
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
- Read the documentation files
|
||||
- Check the diagrams for visual understanding
|
||||
- Follow the learning path for structured learning
|
||||
- Refer to the complete summary for reference
|
||||
|
||||
---
|
||||
|
||||
**Happy Learning! 🚀**
|
||||
|
||||
Start with **01-ARCHITECTURE-OVERVIEW.md** and **09-DIAGRAMS.md** for the big picture.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-agent-core",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -29,7 +29,8 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
"yaml": "2.9.0"
|
||||
|
||||
@@ -34,8 +34,10 @@ import type {
|
||||
AgentHarnessResources,
|
||||
AgentHarnessStreamOptions,
|
||||
AgentHarnessStreamOptionsPatch,
|
||||
AgentHarnessSystemPrompt,
|
||||
AgentHarnessTool,
|
||||
AgentHarnessToolContextSource,
|
||||
CompactResult,
|
||||
ExecutionEnv,
|
||||
NavigateTreeResult,
|
||||
PendingSessionWrite,
|
||||
PromptTemplate,
|
||||
@@ -149,12 +151,14 @@ function normalizeHookError(error: unknown): AgentHarnessError {
|
||||
}
|
||||
|
||||
interface AgentHarnessTurnState<
|
||||
TContext extends object | undefined,
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentTool = AgentTool,
|
||||
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
|
||||
> {
|
||||
messages: AgentMessage[];
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
toolContext: TContext;
|
||||
streamOptions: AgentHarnessStreamOptions;
|
||||
sessionId: string;
|
||||
systemPrompt: string;
|
||||
@@ -165,11 +169,11 @@ interface AgentHarnessTurnState<
|
||||
}
|
||||
|
||||
export class AgentHarness<
|
||||
TContext extends object | undefined = undefined,
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentTool = AgentTool,
|
||||
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
|
||||
> {
|
||||
readonly env: ExecutionEnv;
|
||||
private session: Session;
|
||||
readonly models: Models;
|
||||
private phase: AgentHarnessPhase = "idle";
|
||||
@@ -178,7 +182,8 @@ export class AgentHarness<
|
||||
private pendingSessionWrites: PendingSessionWrite[] = [];
|
||||
private model: Model<any>;
|
||||
private thinkingLevel: ThinkingLevel;
|
||||
private systemPrompt: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>["systemPrompt"];
|
||||
private systemPrompt: AgentHarnessSystemPrompt<TContext, TSkill, TPromptTemplate, TTool> | undefined;
|
||||
private toolContext: AgentHarnessToolContextSource<TContext> | undefined;
|
||||
private streamOptions: AgentHarnessStreamOptions;
|
||||
private retry: RetryPolicy | undefined;
|
||||
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
@@ -191,14 +196,14 @@ export class AgentHarness<
|
||||
private nextTurnQueue: AgentMessage[] = [];
|
||||
private handlers = new Map<string, Set<AgentHarnessHandler>>();
|
||||
|
||||
constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>) {
|
||||
this.env = options.env;
|
||||
constructor(options: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>) {
|
||||
this.session = options.session;
|
||||
this.models = options.models;
|
||||
this.resources = options.resources ?? {};
|
||||
this.streamOptions = cloneStreamOptions(options.streamOptions);
|
||||
this.retry = options.retry;
|
||||
this.systemPrompt = options.systemPrompt;
|
||||
this.toolContext = options.toolContext;
|
||||
this.validateUniqueNames(
|
||||
(options.tools ?? []).map((tool) => tool.name),
|
||||
"Duplicate tool name(s)",
|
||||
@@ -332,10 +337,25 @@ export class AgentHarness<
|
||||
};
|
||||
}
|
||||
|
||||
private async createTurnState(): Promise<AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>> {
|
||||
private async resolveToolContext(): Promise<TContext> {
|
||||
if (typeof this.toolContext === "function") {
|
||||
return await (this.toolContext as () => TContext | Promise<TContext>)();
|
||||
}
|
||||
return this.toolContext as TContext;
|
||||
}
|
||||
|
||||
private bindToolContext(tool: TTool, context: TContext): AgentTool {
|
||||
return {
|
||||
...tool,
|
||||
execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate, context),
|
||||
};
|
||||
}
|
||||
|
||||
private async createTurnState(): Promise<AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>> {
|
||||
const context = await this.session.buildContext();
|
||||
const resources = this.getResources();
|
||||
const sessionMetadata = await this.session.getMetadata();
|
||||
const toolContext = await this.resolveToolContext();
|
||||
const tools = [...this.tools.values()];
|
||||
const activeTools = this.activeToolNames
|
||||
.map((name) => this.tools.get(name))
|
||||
@@ -345,7 +365,6 @@ export class AgentHarness<
|
||||
systemPrompt = this.systemPrompt;
|
||||
} else if (this.systemPrompt) {
|
||||
systemPrompt = await this.systemPrompt({
|
||||
env: this.env,
|
||||
session: this.session,
|
||||
model: this.model,
|
||||
thinkingLevel: this.thinkingLevel,
|
||||
@@ -356,6 +375,7 @@ export class AgentHarness<
|
||||
return {
|
||||
messages: context.messages,
|
||||
resources,
|
||||
toolContext,
|
||||
streamOptions: cloneStreamOptions(this.streamOptions),
|
||||
sessionId: sessionMetadata.id,
|
||||
systemPrompt,
|
||||
@@ -367,17 +387,19 @@ export class AgentHarness<
|
||||
}
|
||||
|
||||
private createContext(
|
||||
turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
|
||||
turnState: AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>,
|
||||
systemPrompt?: string,
|
||||
): AgentContext {
|
||||
return {
|
||||
systemPrompt: systemPrompt ?? turnState.systemPrompt,
|
||||
messages: turnState.messages.slice(),
|
||||
tools: turnState.activeTools.slice(),
|
||||
tools: turnState.activeTools.map((tool) => this.bindToolContext(tool, turnState.toolContext)),
|
||||
};
|
||||
}
|
||||
|
||||
private createStreamFn(getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>): StreamFn {
|
||||
private createStreamFn(
|
||||
getTurnState: () => AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>,
|
||||
): StreamFn {
|
||||
return async (model, context, streamOptions) => {
|
||||
const turnState = getTurnState();
|
||||
const snapshotOptions: AgentHarnessStreamOptions = { ...turnState.streamOptions };
|
||||
@@ -418,8 +440,8 @@ export class AgentHarness<
|
||||
}
|
||||
|
||||
private createLoopConfig(
|
||||
getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
|
||||
setTurnState: (turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => void,
|
||||
getTurnState: () => AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>,
|
||||
setTurnState: (turnState: AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>) => void,
|
||||
): AgentLoopConfig {
|
||||
const turnState = getTurnState();
|
||||
return {
|
||||
@@ -557,7 +579,7 @@ export class AgentHarness<
|
||||
}
|
||||
|
||||
private async executeTurn(
|
||||
turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
|
||||
turnState: AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>,
|
||||
text: string,
|
||||
options?: { images?: ImageContent[] },
|
||||
): Promise<AssistantMessage> {
|
||||
@@ -584,7 +606,7 @@ export class AgentHarness<
|
||||
|
||||
const abortController = new AbortController();
|
||||
const getTurnState = () => activeTurnState;
|
||||
const setTurnState = (nextTurnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => {
|
||||
const setTurnState = (nextTurnState: AgentHarnessTurnState<TContext, TSkill, TPromptTemplate, TTool>) => {
|
||||
activeTurnState = nextTurnState;
|
||||
};
|
||||
this.runAbortController = abortController;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type SimpleStreamOptions,
|
||||
type TextContent,
|
||||
type Usage,
|
||||
uuidv7,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
|
||||
import {
|
||||
@@ -122,7 +123,18 @@ export async function completeSimpleWithRetries(
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<AssistantMessage> {
|
||||
return retryAssistantCall(() => models.completeSimple(model, context, options), retry, options.signal, callbacks);
|
||||
// Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused.
|
||||
const requestOptions: SimpleStreamOptions = {
|
||||
...options,
|
||||
cacheRetention: "none",
|
||||
sessionId: uuidv7(),
|
||||
};
|
||||
return retryAssistantCall(
|
||||
() => models.completeSimple(model, context, requestOptions),
|
||||
retry,
|
||||
requestOptions.signal,
|
||||
callbacks,
|
||||
);
|
||||
}
|
||||
|
||||
function combineUsage(first: Usage, second: Usage): Usage {
|
||||
|
||||
+140
-34
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, createReadStream } from "node:fs";
|
||||
import {
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
type ExecutionEnv,
|
||||
ExecutionError,
|
||||
@@ -25,11 +26,13 @@ import {
|
||||
type FileKind,
|
||||
ok,
|
||||
type Result,
|
||||
type ShellExecOptions,
|
||||
toError,
|
||||
} from "../types.ts";
|
||||
|
||||
const MAX_TIMEOUT_MS = 2_147_483_647;
|
||||
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
|
||||
const EXIT_STDIO_GRACE_MS = 100;
|
||||
|
||||
function resolveTimeoutMs(timeout: number | undefined): Result<number | undefined, ExecutionError> {
|
||||
if (timeout === undefined) return ok(undefined);
|
||||
@@ -45,7 +48,19 @@ function resolveTimeoutMs(timeout: number | undefined): Result<number | undefine
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, path: string): string {
|
||||
return isAbsolute(path) ? path : resolve(cwd, path);
|
||||
let normalized = path;
|
||||
if (normalized === "~") {
|
||||
normalized = homedir();
|
||||
} else if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
|
||||
normalized = join(homedir(), normalized.slice(2));
|
||||
} else if (normalized.startsWith("file://")) {
|
||||
try {
|
||||
normalized = fileURLToPath(normalized);
|
||||
} catch {
|
||||
// Keep malformed URLs as ordinary paths so filesystem methods preserve their non-throwing contract.
|
||||
}
|
||||
}
|
||||
return isAbsolute(normalized) ? resolve(normalized) : resolve(cwd, normalized);
|
||||
}
|
||||
|
||||
function fileKindFromStats(stats: {
|
||||
@@ -197,7 +212,16 @@ async function getShellConfig(customShellPath?: string): Promise<Result<ShellCon
|
||||
if (bashOnPath) {
|
||||
return ok(getBashShellConfig(bashOnPath));
|
||||
}
|
||||
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
|
||||
return err(
|
||||
new ExecutionError(
|
||||
"shell_unavailable",
|
||||
`No bash shell found. Options:\n` +
|
||||
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
||||
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
||||
" 3. Configure an explicit shellPath\n\n" +
|
||||
`Searched Git Bash in:\n${candidates.map((path) => ` ${path}`).join("\n")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (await pathExists("/bin/bash")) {
|
||||
@@ -210,7 +234,12 @@ async function getShellConfig(customShellPath?: string): Promise<Result<ShellCon
|
||||
return ok({ shell: "sh", args: ["-c"] });
|
||||
}
|
||||
|
||||
function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
function getShellEnv(
|
||||
baseEnv?: NodeJS.ProcessEnv,
|
||||
extraEnv?: Record<string, string>,
|
||||
inheritEnv = true,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!inheritEnv) return { ...extraEnv };
|
||||
return {
|
||||
...process.env,
|
||||
...baseEnv,
|
||||
@@ -243,10 +272,80 @@ function killProcessTree(pid: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForChildProcess(child: ChildProcess): Promise<number | null> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
let settled = false;
|
||||
let exited = false;
|
||||
let exitCode: number | null = null;
|
||||
let postExitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stdoutEnded = child.stdout === null;
|
||||
let stderrEnded = child.stderr === null;
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (postExitTimer) clearTimeout(postExitTimer);
|
||||
child.removeListener("error", onError);
|
||||
child.removeListener("exit", onExit);
|
||||
child.removeListener("close", onClose);
|
||||
child.stdout?.removeListener("end", onStdoutEnd);
|
||||
child.stderr?.removeListener("end", onStderrEnd);
|
||||
child.stdout?.removeListener("data", onData);
|
||||
child.stderr?.removeListener("data", onData);
|
||||
};
|
||||
const finalize = (code: number | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
resolvePromise(code);
|
||||
};
|
||||
const maybeFinalizeAfterExit = (): void => {
|
||||
if (exited && stdoutEnded && stderrEnded) finalize(exitCode);
|
||||
};
|
||||
const armIdleTimer = (): void => {
|
||||
if (postExitTimer) clearTimeout(postExitTimer);
|
||||
postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
|
||||
};
|
||||
const onData = (): void => {
|
||||
if (exited && !settled) armIdleTimer();
|
||||
};
|
||||
const onStdoutEnd = (): void => {
|
||||
stdoutEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
const onStderrEnd = (): void => {
|
||||
stderrEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onExit = (code: number | null): void => {
|
||||
exited = true;
|
||||
exitCode = code;
|
||||
maybeFinalizeAfterExit();
|
||||
if (!settled) armIdleTimer();
|
||||
};
|
||||
const onClose = (code: number | null): void => finalize(code);
|
||||
|
||||
child.stdout?.once("end", onStdoutEnd);
|
||||
child.stderr?.once("end", onStderrEnd);
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
child.once("close", onClose);
|
||||
});
|
||||
}
|
||||
|
||||
export class NodeExecutionEnv implements ExecutionEnv {
|
||||
cwd: string;
|
||||
private shellPath?: string;
|
||||
private shellEnv?: NodeJS.ProcessEnv;
|
||||
private activeChildPids = new Set<number>();
|
||||
|
||||
constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) {
|
||||
this.cwd = options.cwd;
|
||||
@@ -264,14 +363,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
|
||||
async exec(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
},
|
||||
options?: ShellExecOptions,
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
||||
const timeoutMsResult = resolveTimeoutMs(options?.timeout);
|
||||
@@ -281,6 +373,18 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
|
||||
const shellConfig = await getShellConfig(this.shellPath);
|
||||
if (!shellConfig.ok) return shellConfig;
|
||||
try {
|
||||
await access(cwd, constants.F_OK);
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
return err(
|
||||
new ExecutionError(
|
||||
"spawn_error",
|
||||
`Working directory does not exist: ${cwd}\nCannot execute bash commands.`,
|
||||
cause,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return await new Promise((resolvePromise) => {
|
||||
let stdout = "";
|
||||
@@ -300,6 +404,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
|
||||
if (child?.pid) this.activeChildPids.delete(child.pid);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolvePromise(result);
|
||||
@@ -313,11 +418,12 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
{
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: getShellEnv(this.shellEnv, options?.env),
|
||||
env: getShellEnv(this.shellEnv, options?.env, options?.inheritEnv),
|
||||
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (child.pid) this.activeChildPids.add(child.pid);
|
||||
if (commandFromStdin) {
|
||||
child.stdin?.on("error", () => {});
|
||||
child.stdin?.end(command);
|
||||
@@ -369,25 +475,24 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
settle(err(new ExecutionError("spawn_error", error.message, error)));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (callbackError) {
|
||||
settle(err(callbackError));
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
|
||||
return;
|
||||
}
|
||||
if (options?.abortSignal?.aborted) {
|
||||
settle(err(new ExecutionError("aborted", "aborted")));
|
||||
return;
|
||||
}
|
||||
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
|
||||
});
|
||||
void waitForChildProcess(child).then(
|
||||
(code) => {
|
||||
if (callbackError) {
|
||||
settle(err(callbackError));
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
|
||||
return;
|
||||
}
|
||||
if (options?.abortSignal?.aborted) {
|
||||
settle(err(new ExecutionError("aborted", "aborted")));
|
||||
return;
|
||||
}
|
||||
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
|
||||
},
|
||||
(error: Error) => settle(err(new ExecutionError("spawn_error", error.message, error))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -564,6 +669,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
// nothing to clean up for the local node implementation
|
||||
for (const pid of this.activeChildPids) killProcessTree(pid);
|
||||
this.activeChildPids.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { AgentHarnessTool } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
import { executeShellWithCapture, type ShellCaptureProgress } from "../utils/shell-output.ts";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "../utils/truncate.ts";
|
||||
import type { ExecutionToolContext } from "./tool-context.ts";
|
||||
|
||||
const MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
||||
const BASH_UPDATE_THROTTLE_MS = 100;
|
||||
|
||||
const bashSchema = Type.Object({
|
||||
command: Type.String({ description: "Bash command to execute" }),
|
||||
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
||||
});
|
||||
|
||||
export type BashToolInput = Static<typeof bashSchema>;
|
||||
|
||||
export interface BashToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
}
|
||||
|
||||
export interface BashExecution {
|
||||
command: string;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
inheritEnv: boolean;
|
||||
}
|
||||
|
||||
export type BashPrepare<TContext extends ExecutionToolContext = ExecutionToolContext> = (
|
||||
execution: BashExecution,
|
||||
context: TContext,
|
||||
signal?: AbortSignal,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export interface BashToolOptions<TContext extends ExecutionToolContext = ExecutionToolContext> {
|
||||
commandPrefix?: string;
|
||||
prepare?: BashPrepare<TContext>;
|
||||
}
|
||||
|
||||
function validateTimeout(timeout: number | undefined): void {
|
||||
if (timeout === undefined) return;
|
||||
if (!Number.isFinite(timeout) || timeout <= 0) {
|
||||
throw new Error("Invalid timeout: must be a finite number of seconds");
|
||||
}
|
||||
if (timeout > MAX_TIMEOUT_SECONDS) {
|
||||
throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBashTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
|
||||
options?: BashToolOptions<TContext>,
|
||||
): AgentHarnessTool<TContext, typeof bashSchema, BashToolDetails | undefined> {
|
||||
return {
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
|
||||
parameters: bashSchema,
|
||||
async execute(_toolCallId, { command, timeout }, signal, onUpdate, context) {
|
||||
validateTimeout(timeout);
|
||||
const { env } = context;
|
||||
const execution: BashExecution = {
|
||||
command: options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command,
|
||||
cwd: env.cwd,
|
||||
env: {},
|
||||
inheritEnv: true,
|
||||
};
|
||||
await options?.prepare?.(execution, context, signal);
|
||||
let getLatestProgress: (() => ShellCaptureProgress) | undefined;
|
||||
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let updateDirty = false;
|
||||
let lastUpdateAt = 0;
|
||||
|
||||
const emitOutputUpdate = (): void => {
|
||||
if (!onUpdate || !updateDirty || !getLatestProgress) return;
|
||||
updateDirty = false;
|
||||
lastUpdateAt = Date.now();
|
||||
const progress = getLatestProgress();
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: progress.output }],
|
||||
details: {
|
||||
truncation: progress.truncation.truncated ? progress.truncation : undefined,
|
||||
fullOutputPath: progress.fullOutputPath,
|
||||
},
|
||||
});
|
||||
};
|
||||
const clearUpdateTimer = (): void => {
|
||||
if (!updateTimer) return;
|
||||
clearTimeout(updateTimer);
|
||||
updateTimer = undefined;
|
||||
};
|
||||
const scheduleOutputUpdate = (): void => {
|
||||
if (!onUpdate) return;
|
||||
updateDirty = true;
|
||||
const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
|
||||
if (delay <= 0) {
|
||||
clearUpdateTimer();
|
||||
emitOutputUpdate();
|
||||
return;
|
||||
}
|
||||
updateTimer ??= setTimeout(() => {
|
||||
updateTimer = undefined;
|
||||
emitOutputUpdate();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
onUpdate?.({ content: [], details: undefined });
|
||||
try {
|
||||
const capture = getOrThrow(
|
||||
await executeShellWithCapture(env, execution.command, {
|
||||
cwd: execution.cwd,
|
||||
env: execution.env,
|
||||
inheritEnv: execution.inheritEnv,
|
||||
timeout,
|
||||
abortSignal: signal,
|
||||
returnExecutionErrors: true,
|
||||
onChunk: (_chunk, getProgress) => {
|
||||
getLatestProgress = getProgress;
|
||||
scheduleOutputUpdate();
|
||||
},
|
||||
}),
|
||||
);
|
||||
clearUpdateTimer();
|
||||
getLatestProgress = () => capture;
|
||||
updateDirty = true;
|
||||
emitOutputUpdate();
|
||||
|
||||
let outputText = capture.output;
|
||||
let details: BashToolDetails | undefined;
|
||||
if (capture.truncation.truncated) {
|
||||
details = { truncation: capture.truncation, fullOutputPath: capture.fullOutputPath };
|
||||
const startLine = capture.truncation.totalLines - capture.truncation.outputLines + 1;
|
||||
const endLine = capture.truncation.totalLines;
|
||||
if (capture.truncation.lastLinePartial) {
|
||||
const lastLineSize = formatSize(capture.lastLineBytes);
|
||||
outputText += `\n\n[Showing last ${formatSize(capture.truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${capture.fullOutputPath}]`;
|
||||
} else if (capture.truncation.truncatedBy === "lines") {
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines}. Full output: ${capture.fullOutputPath}]`;
|
||||
} else {
|
||||
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${capture.truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${capture.fullOutputPath}]`;
|
||||
}
|
||||
}
|
||||
|
||||
const appendStatus = (status: string): string => `${outputText ? `${outputText}\n\n` : ""}${status}`;
|
||||
if (capture.cancelled) throw new Error(appendStatus("Command aborted"));
|
||||
if (capture.executionError?.code === "timeout") {
|
||||
throw new Error(appendStatus(`Command timed out after ${timeout} seconds`), {
|
||||
cause: capture.executionError,
|
||||
});
|
||||
}
|
||||
if (capture.executionError) throw capture.executionError;
|
||||
if (capture.exitCode !== 0 && capture.exitCode !== undefined) {
|
||||
throw new Error(appendStatus(`Command exited with code ${capture.exitCode}`));
|
||||
}
|
||||
return { content: [{ type: "text", text: outputText || "(no output)" }], details };
|
||||
} finally {
|
||||
clearUpdateTimer();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Shared diff computation utilities for the edit and similar tools.
|
||||
*/
|
||||
|
||||
import * as Diff from "diff";
|
||||
|
||||
export function detectLineEnding(content: string): "\r\n" | "\n" {
|
||||
const crlfIdx = content.indexOf("\r\n");
|
||||
const lfIdx = content.indexOf("\n");
|
||||
if (lfIdx === -1) return "\n";
|
||||
if (crlfIdx === -1) return "\n";
|
||||
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
||||
}
|
||||
|
||||
export function normalizeToLF(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
|
||||
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize text for fuzzy matching. Applies progressive transformations:
|
||||
* - Strip trailing whitespace from each line
|
||||
* - Normalize smart quotes to ASCII equivalents
|
||||
* - Normalize Unicode dashes/hyphens to ASCII hyphen
|
||||
* - Normalize special Unicode spaces to regular space
|
||||
*/
|
||||
export function normalizeForFuzzyMatch(text: string): string {
|
||||
return (
|
||||
text
|
||||
.normalize("NFKC")
|
||||
// Strip trailing whitespace per line
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
// Smart single quotes → '
|
||||
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
||||
// Smart double quotes → "
|
||||
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
||||
// Various dashes/hyphens → -
|
||||
// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
|
||||
// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
|
||||
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
||||
// Special spaces → regular space
|
||||
// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
|
||||
// U+205F medium math space, U+3000 ideographic space
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
||||
);
|
||||
}
|
||||
|
||||
function splitLinesWithEndings(content: string): string[] {
|
||||
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
||||
}
|
||||
|
||||
interface LineSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MatchedEdit {
|
||||
editIndex: number;
|
||||
matchIndex: number;
|
||||
matchLength: number;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
type TextReplacement = Pick<MatchedEdit, "matchIndex" | "matchLength" | "newText">;
|
||||
|
||||
function getLineSpans(content: string): LineSpan[] {
|
||||
let offset = 0;
|
||||
return splitLinesWithEndings(content).map((line) => {
|
||||
const span = { start: offset, end: offset + line.length };
|
||||
offset = span.end;
|
||||
return span;
|
||||
});
|
||||
}
|
||||
|
||||
function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) {
|
||||
const replacementStart = replacement.matchIndex;
|
||||
const replacementEnd = replacement.matchIndex + replacement.matchLength;
|
||||
|
||||
let startLine = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (replacementStart >= line.start && replacementStart < line.end) {
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startLine === -1) {
|
||||
throw new Error("Replacement range is outside the base content.");
|
||||
}
|
||||
|
||||
let endLine = startLine;
|
||||
while (endLine < lines.length && lines[endLine].end < replacementEnd) {
|
||||
endLine++;
|
||||
}
|
||||
if (endLine >= lines.length) {
|
||||
throw new Error("Replacement range is outside the base content.");
|
||||
}
|
||||
|
||||
return { startLine, endLine: endLine + 1 };
|
||||
}
|
||||
|
||||
function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string {
|
||||
let result = content;
|
||||
for (let i = replacements.length - 1; i >= 0; i--) {
|
||||
const replacement = replacements[i];
|
||||
const matchIndex = replacement.matchIndex - offset;
|
||||
result =
|
||||
result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply replacements matched against `baseContent` to `originalContent` while
|
||||
* preserving unchanged line blocks from the original.
|
||||
*
|
||||
* This is useful when `baseContent` is a normalized view of the original. Each
|
||||
* replacement is widened to the lines it actually touches, those touched lines
|
||||
* are rewritten from the normalized base, and all other lines are copied back
|
||||
* from `originalContent`. The actual replacement ranges drive preservation so
|
||||
* duplicate normalized lines cannot be aligned to the wrong occurrence.
|
||||
*/
|
||||
export function applyReplacementsPreservingUnchangedLines(
|
||||
originalContent: string,
|
||||
baseContent: string,
|
||||
replacements: TextReplacement[],
|
||||
): string {
|
||||
const originalLines = splitLinesWithEndings(originalContent);
|
||||
const baseLines = getLineSpans(baseContent);
|
||||
if (originalLines.length !== baseLines.length) {
|
||||
throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
|
||||
}
|
||||
|
||||
const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = [];
|
||||
const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
|
||||
for (const replacement of sortedReplacements) {
|
||||
const range = getReplacementLineRange(baseLines, replacement);
|
||||
const current = groups[groups.length - 1];
|
||||
if (current && range.startLine < current.endLine) {
|
||||
current.endLine = Math.max(current.endLine, range.endLine);
|
||||
current.replacements.push(replacement);
|
||||
continue;
|
||||
}
|
||||
groups.push({ ...range, replacements: [replacement] });
|
||||
}
|
||||
|
||||
let originalLineIndex = 0;
|
||||
let result = "";
|
||||
for (const group of groups) {
|
||||
result += originalLines.slice(originalLineIndex, group.startLine).join("");
|
||||
|
||||
const groupStartOffset = baseLines[group.startLine].start;
|
||||
const groupEndOffset = baseLines[group.endLine - 1].end;
|
||||
result += applyReplacements(
|
||||
baseContent.slice(groupStartOffset, groupEndOffset),
|
||||
group.replacements,
|
||||
groupStartOffset,
|
||||
);
|
||||
originalLineIndex = group.endLine;
|
||||
}
|
||||
result += originalLines.slice(originalLineIndex).join("");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface FuzzyMatchResult {
|
||||
/** Whether a match was found */
|
||||
found: boolean;
|
||||
/** The index where the match starts (in the content that should be used for replacement) */
|
||||
index: number;
|
||||
/** Length of the matched text */
|
||||
matchLength: number;
|
||||
/** Whether fuzzy matching was used (false = exact match) */
|
||||
usedFuzzyMatch: boolean;
|
||||
/**
|
||||
* The content to use for replacement operations.
|
||||
* When exact match: original content. When fuzzy match: normalized content.
|
||||
*/
|
||||
contentForReplacement: string;
|
||||
}
|
||||
|
||||
export interface Edit {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export interface AppliedEditsResult {
|
||||
baseContent: string;
|
||||
newContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find oldText in content, trying exact match first, then fuzzy match.
|
||||
* When fuzzy matching is used, the returned contentForReplacement is the
|
||||
* fuzzy-normalized version of the content (trailing whitespace stripped,
|
||||
* Unicode quotes/dashes normalized to ASCII).
|
||||
*/
|
||||
export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {
|
||||
// Try exact match first
|
||||
const exactIndex = content.indexOf(oldText);
|
||||
if (exactIndex !== -1) {
|
||||
return {
|
||||
found: true,
|
||||
index: exactIndex,
|
||||
matchLength: oldText.length,
|
||||
usedFuzzyMatch: false,
|
||||
contentForReplacement: content,
|
||||
};
|
||||
}
|
||||
|
||||
// Try fuzzy match - work entirely in normalized space
|
||||
const fuzzyContent = normalizeForFuzzyMatch(content);
|
||||
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
||||
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
|
||||
|
||||
if (fuzzyIndex === -1) {
|
||||
return {
|
||||
found: false,
|
||||
index: -1,
|
||||
matchLength: 0,
|
||||
usedFuzzyMatch: false,
|
||||
contentForReplacement: content,
|
||||
};
|
||||
}
|
||||
|
||||
// When fuzzy matching, return offsets in normalized space. Callers can use
|
||||
// the normalized content to compute replacements, then decide how much of
|
||||
// that normalized output should be written back.
|
||||
return {
|
||||
found: true,
|
||||
index: fuzzyIndex,
|
||||
matchLength: fuzzyOldText.length,
|
||||
usedFuzzyMatch: true,
|
||||
contentForReplacement: fuzzyContent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
|
||||
export function stripBom(content: string): { bom: string; text: string } {
|
||||
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, oldText: string): number {
|
||||
const fuzzyContent = normalizeForFuzzyMatch(content);
|
||||
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
||||
return fuzzyContent.split(fuzzyOldText).length - 1;
|
||||
}
|
||||
|
||||
function getNotFoundError(path: string, editIndex: number, totalEdits: number): Error {
|
||||
if (totalEdits === 1) {
|
||||
return new Error(
|
||||
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`,
|
||||
);
|
||||
}
|
||||
|
||||
function getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error {
|
||||
if (totalEdits === 1) {
|
||||
return new Error(
|
||||
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`,
|
||||
);
|
||||
}
|
||||
|
||||
function getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error {
|
||||
if (totalEdits === 1) {
|
||||
return new Error(`oldText must not be empty in ${path}.`);
|
||||
}
|
||||
return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);
|
||||
}
|
||||
|
||||
function getNoChangeError(path: string, totalEdits: number): Error {
|
||||
if (totalEdits === 1) {
|
||||
return new Error(
|
||||
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
|
||||
);
|
||||
}
|
||||
return new Error(`No changes made to ${path}. The replacements produced identical content.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one or more exact-text replacements to LF-normalized content.
|
||||
*
|
||||
* All edits are matched against the same original content. Replacements are
|
||||
* then applied in reverse order so offsets remain stable. If any edit needs
|
||||
* fuzzy matching, the operation runs in fuzzy-normalized content space and then
|
||||
* overlays those line-level changes onto the original content so unchanged line
|
||||
* blocks keep their original bytes.
|
||||
*/
|
||||
export function applyEditsToNormalizedContent(
|
||||
normalizedContent: string,
|
||||
edits: Edit[],
|
||||
path: string,
|
||||
): AppliedEditsResult {
|
||||
const normalizedEdits = edits.map((edit) => ({
|
||||
oldText: normalizeToLF(edit.oldText),
|
||||
newText: normalizeToLF(edit.newText),
|
||||
}));
|
||||
|
||||
for (let i = 0; i < normalizedEdits.length; i++) {
|
||||
if (normalizedEdits[i].oldText.length === 0) {
|
||||
throw getEmptyOldTextError(path, i, normalizedEdits.length);
|
||||
}
|
||||
}
|
||||
|
||||
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
|
||||
const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
|
||||
const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
|
||||
|
||||
const matchedEdits: MatchedEdit[] = [];
|
||||
for (let i = 0; i < normalizedEdits.length; i++) {
|
||||
const edit = normalizedEdits[i];
|
||||
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
|
||||
if (!matchResult.found) {
|
||||
throw getNotFoundError(path, i, normalizedEdits.length);
|
||||
}
|
||||
|
||||
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
|
||||
if (occurrences > 1) {
|
||||
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
|
||||
}
|
||||
|
||||
matchedEdits.push({
|
||||
editIndex: i,
|
||||
matchIndex: matchResult.index,
|
||||
matchLength: matchResult.matchLength,
|
||||
newText: edit.newText,
|
||||
});
|
||||
}
|
||||
|
||||
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
|
||||
for (let i = 1; i < matchedEdits.length; i++) {
|
||||
const previous = matchedEdits[i - 1];
|
||||
const current = matchedEdits[i];
|
||||
if (previous.matchIndex + previous.matchLength > current.matchIndex) {
|
||||
throw new Error(
|
||||
`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const baseContent = normalizedContent;
|
||||
const newContent = usedFuzzyMatch
|
||||
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
|
||||
: applyReplacements(replacementBaseContent, matchedEdits);
|
||||
|
||||
if (baseContent === newContent) {
|
||||
throw getNoChangeError(path, normalizedEdits.length);
|
||||
}
|
||||
|
||||
return { baseContent, newContent };
|
||||
}
|
||||
|
||||
/** Generate a standard unified patch. */
|
||||
export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string {
|
||||
return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
|
||||
context: contextLines,
|
||||
headerOptions: Diff.FILE_HEADERS_ONLY,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a display-oriented diff string with line numbers and context.
|
||||
* Returns both the diff string and the first changed line number (in the new file).
|
||||
*/
|
||||
export function generateDiffString(
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
contextLines = 4,
|
||||
): { diff: string; firstChangedLine: number | undefined } {
|
||||
const parts = Diff.diffLines(oldContent, newContent);
|
||||
const output: string[] = [];
|
||||
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
const maxLineNum = Math.max(oldLines.length, newLines.length);
|
||||
const lineNumWidth = String(maxLineNum).length;
|
||||
|
||||
let oldLineNum = 1;
|
||||
let newLineNum = 1;
|
||||
let lastWasChange = false;
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const raw = part.value.split("\n");
|
||||
if (raw[raw.length - 1] === "") {
|
||||
raw.pop();
|
||||
}
|
||||
|
||||
if (part.added || part.removed) {
|
||||
// Capture the first changed line (in the new file)
|
||||
if (firstChangedLine === undefined) {
|
||||
firstChangedLine = newLineNum;
|
||||
}
|
||||
|
||||
// Show the change
|
||||
for (const line of raw) {
|
||||
if (part.added) {
|
||||
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(`+${lineNum} ${line}`);
|
||||
newLineNum++;
|
||||
} else {
|
||||
// removed
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(`-${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
}
|
||||
}
|
||||
lastWasChange = true;
|
||||
} else {
|
||||
// Context lines - only show a few before/after changes
|
||||
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
||||
const hasLeadingChange = lastWasChange;
|
||||
const hasTrailingChange = nextPartIsChange;
|
||||
|
||||
if (hasLeadingChange && hasTrailingChange) {
|
||||
if (raw.length <= contextLines * 2) {
|
||||
for (const line of raw) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
} else {
|
||||
const leadingLines = raw.slice(0, contextLines);
|
||||
const trailingLines = raw.slice(raw.length - contextLines);
|
||||
const skippedLines = raw.length - leadingLines.length - trailingLines.length;
|
||||
|
||||
for (const line of leadingLines) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
|
||||
for (const line of trailingLines) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
} else if (hasLeadingChange) {
|
||||
const shownLines = raw.slice(0, contextLines);
|
||||
const skippedLines = raw.length - shownLines.length;
|
||||
|
||||
for (const line of shownLines) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
|
||||
if (skippedLines > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
}
|
||||
} else if (hasTrailingChange) {
|
||||
const skippedLines = Math.max(0, raw.length - contextLines);
|
||||
if (skippedLines > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
}
|
||||
|
||||
for (const line of raw.slice(skippedLines)) {
|
||||
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
|
||||
output.push(` ${lineNum} ${line}`);
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
} else {
|
||||
// Skip these context lines entirely
|
||||
oldLineNum += raw.length;
|
||||
newLineNum += raw.length;
|
||||
}
|
||||
|
||||
lastWasChange = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { diff: output.join("\n"), firstChangedLine };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { AgentHarnessTool, FileError } from "../types.ts";
|
||||
import {
|
||||
applyEditsToNormalizedContent,
|
||||
detectLineEnding,
|
||||
type Edit,
|
||||
generateDiffString,
|
||||
generateUnifiedPatch,
|
||||
normalizeToLF,
|
||||
restoreLineEndings,
|
||||
stripBom,
|
||||
} from "./edit-diff.ts";
|
||||
import { withFileMutationQueue } from "./file-mutation-queue.ts";
|
||||
import { resolveToolPath } from "./path-utils.ts";
|
||||
import type { ExecutionToolContext } from "./tool-context.ts";
|
||||
|
||||
const replaceEditSchema = Type.Object(
|
||||
{
|
||||
oldText: Type.String({
|
||||
description:
|
||||
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
|
||||
}),
|
||||
newText: Type.String({ description: "Replacement text for this targeted edit." }),
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const editSchema = Type.Object(
|
||||
{
|
||||
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
||||
edits: Type.Array(replaceEditSchema, {
|
||||
description:
|
||||
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
|
||||
}),
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
export type EditToolInput = Static<typeof editSchema>;
|
||||
type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown };
|
||||
|
||||
export interface EditToolDetails {
|
||||
diff: string;
|
||||
patch: string;
|
||||
firstChangedLine?: number;
|
||||
}
|
||||
|
||||
function prepareEditArguments(input: unknown): EditToolInput {
|
||||
if (!input || typeof input !== "object") return input as EditToolInput;
|
||||
const args = input as Record<string, unknown>;
|
||||
if (typeof args.edits === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(args.edits);
|
||||
if (Array.isArray(parsed)) args.edits = parsed;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const legacy = args as LegacyEditToolInput;
|
||||
if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") return args as EditToolInput;
|
||||
const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];
|
||||
edits.push({ oldText: legacy.oldText, newText: legacy.newText });
|
||||
const { oldText: _oldText, newText: _newText, ...rest } = legacy;
|
||||
return { ...rest, edits } as EditToolInput;
|
||||
}
|
||||
|
||||
function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } {
|
||||
if (!Array.isArray(input.edits) || input.edits.length === 0) {
|
||||
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
||||
}
|
||||
return { path: input.path, edits: input.edits };
|
||||
}
|
||||
|
||||
function editAccessError(path: string, error: FileError): Error {
|
||||
return new Error(`Could not edit file: ${path}. Error code: ${error.code}.`, { cause: error });
|
||||
}
|
||||
|
||||
export function createEditTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
|
||||
TContext,
|
||||
typeof editSchema,
|
||||
EditToolDetails | undefined
|
||||
> {
|
||||
return {
|
||||
name: "edit",
|
||||
label: "edit",
|
||||
description:
|
||||
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
|
||||
parameters: editSchema,
|
||||
prepareArguments: prepareEditArguments,
|
||||
async execute(_toolCallId, input, signal, _onUpdate, { env }) {
|
||||
const { path, edits } = validateEditInput(input);
|
||||
const absolutePath = await resolveToolPath(env, path, signal);
|
||||
return withFileMutationQueue(env, absolutePath, async () => {
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
const info = await env.fileInfo(absolutePath, signal);
|
||||
if (!info.ok) throw editAccessError(path, info.error);
|
||||
if (info.value.kind !== "file" && info.value.kind !== "symlink") {
|
||||
throw new Error(`Could not edit file: ${path}. Path is not a file.`);
|
||||
}
|
||||
|
||||
const readResult = await env.readTextFile(absolutePath, signal);
|
||||
if (!readResult.ok) throw editAccessError(path, readResult.error);
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
|
||||
const { bom, text: content } = stripBom(readResult.value);
|
||||
const originalEnding = detectLineEnding(content);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
|
||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
const writeResult = await env.writeFile(absolutePath, finalContent, signal);
|
||||
if (!writeResult.ok) throw editAccessError(path, writeResult.error);
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
|
||||
const diffResult = generateDiffString(baseContent, newContent);
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path}.` }],
|
||||
details: {
|
||||
diff: diffResult.diff,
|
||||
patch: generateUnifiedPatch(path, baseContent, newContent),
|
||||
firstChangedLine: diffResult.firstChangedLine,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ExecutionEnv } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
|
||||
type MutationQueueState = {
|
||||
queues: Map<string, Promise<void>>;
|
||||
registration: Promise<void>;
|
||||
};
|
||||
|
||||
const states = new WeakMap<ExecutionEnv, MutationQueueState>();
|
||||
|
||||
function getState(env: ExecutionEnv): MutationQueueState {
|
||||
let state = states.get(env);
|
||||
if (!state) {
|
||||
state = { queues: new Map(), registration: Promise.resolve() };
|
||||
states.set(env, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function getMutationQueueKey(env: ExecutionEnv, path: string): Promise<string> {
|
||||
const absolutePath = getOrThrow(await env.absolutePath(path));
|
||||
const canonicalPath = await env.canonicalPath(absolutePath);
|
||||
if (canonicalPath.ok) return canonicalPath.value;
|
||||
if (canonicalPath.error.code === "not_found" || canonicalPath.error.code === "not_supported") return absolutePath;
|
||||
throw canonicalPath.error;
|
||||
}
|
||||
|
||||
/** Serialize file mutations targeting the same environment and canonical path. */
|
||||
export async function withFileMutationQueue<T>(env: ExecutionEnv, path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const state = getState(env);
|
||||
const registration = state.registration.then(async () => {
|
||||
const key = await getMutationQueueKey(env, path);
|
||||
const currentQueue = state.queues.get(key) ?? Promise.resolve();
|
||||
|
||||
let releaseNext = () => {};
|
||||
const nextQueue = new Promise<void>((resolve) => {
|
||||
releaseNext = resolve;
|
||||
});
|
||||
const chainedQueue = currentQueue.then(() => nextQueue);
|
||||
state.queues.set(key, chainedQueue);
|
||||
return { key, currentQueue, chainedQueue, releaseNext };
|
||||
});
|
||||
state.registration = registration.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
|
||||
await currentQueue;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
releaseNext();
|
||||
if (state.queues.get(key) === chainedQueue) state.queues.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
export function detectSupportedImageMimeType(buffer: Uint8Array): string | undefined {
|
||||
if (startsWith(buffer, [0xff, 0xd8, 0xff])) return buffer[3] === 0xf7 ? undefined : "image/jpeg";
|
||||
if (startsWith(buffer, PNG_SIGNATURE)) return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : undefined;
|
||||
if (startsWithAscii(buffer, 0, "GIF")) return "image/gif";
|
||||
if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) return "image/webp";
|
||||
if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) return "image/bmp";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function encodeBase64(bytes: Uint8Array): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let output = "";
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const first = bytes[index] ?? 0;
|
||||
const second = bytes[index + 1];
|
||||
const third = bytes[index + 2];
|
||||
output += alphabet[first >> 2];
|
||||
output += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)];
|
||||
output += second === undefined ? "=" : alphabet[((second & 0x0f) << 2) | ((third ?? 0) >> 6)];
|
||||
output += third === undefined ? "=" : alphabet[third & 0x3f];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function isPng(buffer: Uint8Array): boolean {
|
||||
return (
|
||||
buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR")
|
||||
);
|
||||
}
|
||||
|
||||
function isAnimatedPng(buffer: Uint8Array): boolean {
|
||||
let offset = PNG_SIGNATURE.length;
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const chunkLength = readUint32BE(buffer, offset);
|
||||
const chunkTypeOffset = offset + 4;
|
||||
if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true;
|
||||
if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false;
|
||||
const nextOffset = offset + 8 + chunkLength + 4;
|
||||
if (nextOffset <= offset || nextOffset > buffer.length) return false;
|
||||
offset = nextOffset;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBmp(buffer: Uint8Array): boolean {
|
||||
if (buffer.length < 26) return false;
|
||||
const declaredFileSize = readUint32LE(buffer, 2);
|
||||
const pixelDataOffset = readUint32LE(buffer, 10);
|
||||
const dibHeaderSize = readUint32LE(buffer, 14);
|
||||
if (declaredFileSize !== 0 && declaredFileSize < 26) return false;
|
||||
if (pixelDataOffset < 14 + dibHeaderSize) return false;
|
||||
if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) return false;
|
||||
|
||||
let colorPlanes: number;
|
||||
let bitsPerPixel: number;
|
||||
if (dibHeaderSize === 12) {
|
||||
colorPlanes = readUint16LE(buffer, 22);
|
||||
bitsPerPixel = readUint16LE(buffer, 24);
|
||||
} else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
|
||||
if (buffer.length < 30) return false;
|
||||
colorPlanes = readUint16LE(buffer, 26);
|
||||
bitsPerPixel = readUint16LE(buffer, 28);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
|
||||
}
|
||||
|
||||
function readUint16LE(buffer: Uint8Array, offset: number): number {
|
||||
return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
|
||||
}
|
||||
|
||||
function readUint32BE(buffer: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(buffer[offset] ?? 0) * 0x1000000 +
|
||||
((buffer[offset + 1] ?? 0) << 16) +
|
||||
((buffer[offset + 2] ?? 0) << 8) +
|
||||
(buffer[offset + 3] ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function readUint32LE(buffer: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(buffer[offset] ?? 0) +
|
||||
((buffer[offset + 1] ?? 0) << 8) +
|
||||
((buffer[offset + 2] ?? 0) << 16) +
|
||||
(buffer[offset + 3] ?? 0) * 0x1000000
|
||||
);
|
||||
}
|
||||
|
||||
function startsWith(buffer: Uint8Array, bytes: number[]): boolean {
|
||||
if (buffer.length < bytes.length) return false;
|
||||
return bytes.every((byte, index) => buffer[index] === byte);
|
||||
}
|
||||
|
||||
function startsWithAscii(buffer: Uint8Array, offset: number, text: string): boolean {
|
||||
if (buffer.length < offset + text.length) return false;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
if (buffer[offset + index] !== text.charCodeAt(index)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
type BashExecution,
|
||||
type BashPrepare,
|
||||
type BashToolDetails,
|
||||
type BashToolInput,
|
||||
type BashToolOptions,
|
||||
createBashTool,
|
||||
} from "./bash.ts";
|
||||
export {
|
||||
createEditTool,
|
||||
type EditToolDetails,
|
||||
type EditToolInput,
|
||||
} from "./edit.ts";
|
||||
export {
|
||||
createReadTool,
|
||||
type ReadImageProcessor,
|
||||
type ReadImageProcessorResult,
|
||||
type ReadToolDetails,
|
||||
type ReadToolInput,
|
||||
type ReadToolOptions,
|
||||
} from "./read.ts";
|
||||
export type { ExecutionToolContext } from "./tool-context.ts";
|
||||
export { createWriteTool, type WriteToolInput } from "./write.ts";
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ExecutionEnv } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
|
||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||
const NARROW_NO_BREAK_SPACE = "\u202F";
|
||||
|
||||
function normalizeToolPath(path: string): string {
|
||||
const normalized = path.replace(UNICODE_SPACES, " ");
|
||||
return normalized.startsWith("@") ? normalized.slice(1) : normalized;
|
||||
}
|
||||
|
||||
export async function resolveToolPath(env: ExecutionEnv, path: string, signal?: AbortSignal): Promise<string> {
|
||||
return getOrThrow(await env.absolutePath(normalizeToolPath(path), signal));
|
||||
}
|
||||
|
||||
export async function resolveReadToolPath(env: ExecutionEnv, path: string, signal?: AbortSignal): Promise<string> {
|
||||
const resolved = await resolveToolPath(env, path, signal);
|
||||
const variants = [
|
||||
resolved,
|
||||
resolved.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`),
|
||||
resolved.normalize("NFD"),
|
||||
resolved.replace(/'/g, "\u2019"),
|
||||
resolved.normalize("NFD").replace(/'/g, "\u2019"),
|
||||
];
|
||||
|
||||
for (const variant of new Set(variants)) {
|
||||
if (getOrThrow(await env.exists(variant, signal))) return variant;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { AgentHarnessTool } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
formatSize,
|
||||
type TruncationResult,
|
||||
truncateHead,
|
||||
} from "../utils/truncate.ts";
|
||||
import { detectSupportedImageMimeType, encodeBase64 } from "./image.ts";
|
||||
import { resolveReadToolPath } from "./path-utils.ts";
|
||||
import type { ExecutionToolContext } from "./tool-context.ts";
|
||||
|
||||
const readSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
||||
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
||||
});
|
||||
|
||||
export type ReadToolInput = Static<typeof readSchema>;
|
||||
|
||||
export interface ReadToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
}
|
||||
|
||||
export type ReadImageProcessorResult =
|
||||
| { ok: true; data: string; mimeType: string; hints: string[] }
|
||||
| { ok: false; message: string };
|
||||
|
||||
export type ReadImageProcessor = (
|
||||
bytes: Uint8Array,
|
||||
mimeType: string,
|
||||
options: { autoResizeImages: boolean },
|
||||
) => Promise<ReadImageProcessorResult>;
|
||||
|
||||
export interface ReadToolOptions {
|
||||
/** Whether an injected image processor should resize images. Default: true. */
|
||||
autoResizeImages?: boolean;
|
||||
/** Optional image conversion/resizing implementation. */
|
||||
imageProcessor?: ReadImageProcessor;
|
||||
}
|
||||
|
||||
export function createReadTool<TContext extends ExecutionToolContext = ExecutionToolContext>(
|
||||
options?: ReadToolOptions,
|
||||
): AgentHarnessTool<TContext, typeof readSchema, ReadToolDetails | undefined> {
|
||||
return {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
||||
parameters: readSchema,
|
||||
async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, { env }) {
|
||||
const absolutePath = await resolveReadToolPath(env, path, signal);
|
||||
const bytes = getOrThrow(await env.readBinaryFile(absolutePath, signal));
|
||||
const mimeType = detectSupportedImageMimeType(bytes);
|
||||
if (mimeType) {
|
||||
if (options?.imageProcessor) {
|
||||
const processed = await options.imageProcessor(bytes, mimeType, {
|
||||
autoResizeImages: options.autoResizeImages ?? true,
|
||||
});
|
||||
if (!processed.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Read image file [${mimeType}]\n${processed.message}` }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
const hints = processed.hints.length > 0 ? `\n${processed.hints.join("\n")}` : "";
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Read image file [${processed.mimeType}]${hints}` },
|
||||
{ type: "image", data: processed.data, mimeType: processed.mimeType },
|
||||
] satisfies Array<TextContent | ImageContent>,
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
if (mimeType === "image/bmp") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Read image file [image/bmp]\n[Image omitted: configure an imageProcessor to convert BMP images.]",
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Read image file [${mimeType}]` },
|
||||
{ type: "image", data: encodeBase64(bytes), mimeType },
|
||||
] satisfies Array<TextContent | ImageContent>,
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const textContent = new TextDecoder().decode(bytes);
|
||||
const allLines = textContent.split("\n");
|
||||
const totalFileLines = allLines.length;
|
||||
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
||||
const startLineDisplay = startLine + 1;
|
||||
if (startLine >= allLines.length) {
|
||||
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
|
||||
}
|
||||
|
||||
let selectedContent: string;
|
||||
let userLimitedLines: number | undefined;
|
||||
if (limit !== undefined) {
|
||||
const endLine = Math.min(startLine + limit, allLines.length);
|
||||
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
||||
userLimitedLines = endLine - startLine;
|
||||
} else {
|
||||
selectedContent = allLines.slice(startLine).join("\n");
|
||||
}
|
||||
|
||||
const truncation = truncateHead(selectedContent);
|
||||
let outputText: string;
|
||||
let details: ReadToolDetails | undefined;
|
||||
if (truncation.firstLineExceedsLimit) {
|
||||
const firstLineSize = formatSize(new TextEncoder().encode(allLines[startLine]).byteLength);
|
||||
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
||||
details = { truncation };
|
||||
} else if (truncation.truncated) {
|
||||
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
||||
const nextOffset = endLineDisplay + 1;
|
||||
outputText = truncation.content;
|
||||
if (truncation.truncatedBy === "lines") {
|
||||
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
|
||||
} else {
|
||||
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
|
||||
}
|
||||
details = { truncation };
|
||||
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
||||
const remaining = allLines.length - (startLine + userLimitedLines);
|
||||
const nextOffset = startLine + userLimitedLines + 1;
|
||||
outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
||||
} else {
|
||||
outputText = truncation.content;
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: outputText }], details };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ExecutionEnv } from "../types.ts";
|
||||
|
||||
/** Filesystem and shell context required by the built-in execution tools. */
|
||||
export interface ExecutionToolContext {
|
||||
env: ExecutionEnv;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { AgentHarnessTool } from "../types.ts";
|
||||
import { getOrThrow } from "../types.ts";
|
||||
import { withFileMutationQueue } from "./file-mutation-queue.ts";
|
||||
import { resolveToolPath } from "./path-utils.ts";
|
||||
import type { ExecutionToolContext } from "./tool-context.ts";
|
||||
|
||||
const writeSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
||||
content: Type.String({ description: "Content to write to the file" }),
|
||||
});
|
||||
|
||||
export type WriteToolInput = Static<typeof writeSchema>;
|
||||
|
||||
export function createWriteTool<TContext extends ExecutionToolContext = ExecutionToolContext>(): AgentHarnessTool<
|
||||
TContext,
|
||||
typeof writeSchema,
|
||||
undefined
|
||||
> {
|
||||
return {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description:
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||
parameters: writeSchema,
|
||||
async execute(_toolCallId, { path, content }, signal, _onUpdate, { env }) {
|
||||
const absolutePath = await resolveToolPath(env, path, signal);
|
||||
return withFileMutationQueue(env, absolutePath, async () => {
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
getOrThrow(await env.writeFile(absolutePath, content, signal));
|
||||
if (signal?.aborted) throw new Error("Operation aborted");
|
||||
return {
|
||||
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
|
||||
details: undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,16 @@ import type {
|
||||
Transport,
|
||||
Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts";
|
||||
import type { Static, TSchema } from "typebox";
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentMessage,
|
||||
AgentTool,
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
QueueMode,
|
||||
ThinkingLevel,
|
||||
} from "../index.ts";
|
||||
import type { Session } from "./session/session.ts";
|
||||
|
||||
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
|
||||
@@ -86,6 +95,27 @@ export interface AgentHarnessResources<
|
||||
skills?: TSkill[];
|
||||
}
|
||||
|
||||
/** Tool definition executed by an {@link AgentHarness} with an application-defined context. */
|
||||
export type AgentHarnessTool<
|
||||
TContext extends object | undefined,
|
||||
TParameters extends TSchema = TSchema,
|
||||
TDetails = unknown,
|
||||
> = Omit<AgentTool<TParameters, TDetails>, "execute"> & {
|
||||
/** Execute the tool call with the context resolved for the current turn snapshot. */
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
||||
context: TContext,
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
};
|
||||
|
||||
/** Static tool context or zero-argument provider resolved for each turn snapshot. */
|
||||
export type AgentHarnessToolContextSource<TContext extends object | undefined> =
|
||||
| TContext
|
||||
| (() => TContext | Promise<TContext>);
|
||||
|
||||
/** Curated provider request options owned by the harness and snapshotted per turn. */
|
||||
export interface AgentHarnessStreamOptions {
|
||||
/** Preferred transport forwarded to the stream function. */
|
||||
@@ -314,8 +344,10 @@ export interface FileSystem {
|
||||
export interface ShellExecOptions {
|
||||
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
|
||||
cwd?: string;
|
||||
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
|
||||
/** Environment variables for the command. Values override inherited defaults when `inheritEnv` is true. */
|
||||
env?: Record<string, string>;
|
||||
/** Whether to inherit the execution environment's default variables. Defaults to true. */
|
||||
inheritEnv?: boolean;
|
||||
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
|
||||
timeout?: number;
|
||||
/** Abort signal used to terminate the command. Defaults to no abort signal. */
|
||||
@@ -861,12 +893,27 @@ export interface BranchSummaryResult {
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
export interface AgentHarnessOptions<
|
||||
export type AgentHarnessSystemPrompt<
|
||||
TContext extends object | undefined = undefined,
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentTool = AgentTool,
|
||||
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
|
||||
> =
|
||||
| string
|
||||
| ((context: {
|
||||
session: Session;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
activeTools: TTool[];
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
}) => string | Promise<string>);
|
||||
|
||||
interface AgentHarnessOptionsBase<
|
||||
TContext extends object | undefined,
|
||||
TSkill extends Skill,
|
||||
TPromptTemplate extends PromptTemplate,
|
||||
TTool extends AgentHarnessTool<TContext>,
|
||||
> {
|
||||
env: ExecutionEnv;
|
||||
session: Session;
|
||||
/**
|
||||
* Provider collection used for all model requests (turn streaming,
|
||||
@@ -880,16 +927,7 @@ export interface AgentHarnessOptions<
|
||||
* Applications own loading/reloading resources and should call `setResources()` with new values.
|
||||
*/
|
||||
resources?: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
systemPrompt?:
|
||||
| string
|
||||
| ((context: {
|
||||
env: ExecutionEnv;
|
||||
session: Session;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
activeTools: TTool[];
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
}) => string | Promise<string>);
|
||||
systemPrompt?: AgentHarnessSystemPrompt<TContext, TSkill, TPromptTemplate, TTool>;
|
||||
/** Curated stream/provider request options. Snapshotted at turn start. */
|
||||
streamOptions?: AgentHarnessStreamOptions;
|
||||
/** Optional retry policy for generated compaction and branch-summary requests. */
|
||||
@@ -901,4 +939,20 @@ export interface AgentHarnessOptions<
|
||||
followUpMode?: QueueMode;
|
||||
}
|
||||
|
||||
export type AgentHarnessOptions<
|
||||
TContext extends object | undefined = undefined,
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentHarnessTool<TContext> = AgentHarnessTool<TContext>,
|
||||
> = AgentHarnessOptionsBase<TContext, TSkill, TPromptTemplate, TTool> &
|
||||
([TContext] extends [undefined]
|
||||
? {
|
||||
/** Context-free harnesses do not need a tool context. */
|
||||
toolContext?: undefined;
|
||||
}
|
||||
: {
|
||||
/** Static context or zero-argument context provider resolved for each turn snapshot. */
|
||||
toolContext: AgentHarnessToolContextSource<TContext>;
|
||||
});
|
||||
|
||||
export type { AgentHarness } from "./agent-harness.ts";
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { type ExecutionEnv, ExecutionError, err, ok, type Result, type ShellExecOptions, toError } from "../types.ts";
|
||||
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts";
|
||||
|
||||
export interface ShellCaptureOptions extends Omit<ShellExecOptions, "onStdout" | "onStderr"> {
|
||||
onChunk?: (chunk: string) => void;
|
||||
export interface ShellCaptureProgress {
|
||||
output: string;
|
||||
truncation: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
lastLineBytes: number;
|
||||
}
|
||||
|
||||
export interface ShellCaptureResult {
|
||||
output: string;
|
||||
export interface ShellCaptureOptions extends Omit<ShellExecOptions, "onStdout" | "onStderr"> {
|
||||
onChunk?: (chunk: string, getProgress: () => ShellCaptureProgress) => void;
|
||||
/** Return shell execution failures with captured output instead of as a failed Result. */
|
||||
returnExecutionErrors?: boolean;
|
||||
}
|
||||
|
||||
export interface ShellCaptureResult extends ShellCaptureProgress {
|
||||
exitCode: number | undefined;
|
||||
cancelled: boolean;
|
||||
truncated: boolean;
|
||||
fullOutputPath?: string;
|
||||
executionError?: ExecutionError;
|
||||
}
|
||||
|
||||
function toExecutionError(error: unknown): ExecutionError {
|
||||
@@ -32,63 +40,104 @@ export function sanitizeBinaryOutput(str: string): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function trimToLastUtf8Bytes(text: string, maxBytes: number, encoder: { encode(input?: string): Uint8Array }): string {
|
||||
const bytes = encoder.encode(text);
|
||||
if (bytes.byteLength <= maxBytes) return text;
|
||||
let start = bytes.byteLength - maxBytes;
|
||||
while (start < bytes.byteLength && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++;
|
||||
return new TextDecoder().decode(bytes.subarray(start));
|
||||
}
|
||||
|
||||
export async function executeShellWithCapture(
|
||||
env: ExecutionEnv,
|
||||
command: string,
|
||||
options?: ShellCaptureOptions,
|
||||
): Promise<Result<ShellCaptureResult, ExecutionError>> {
|
||||
const outputChunks: string[] = [];
|
||||
let outputBytes = 0;
|
||||
let tailOutput = "";
|
||||
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let totalBytes = 0;
|
||||
let completedLines = 0;
|
||||
let hasOpenLine = false;
|
||||
let currentLineBytes = 0;
|
||||
let fullOutputPath: string | undefined;
|
||||
let fullOutputRequested = false;
|
||||
let acceptingOutput = true;
|
||||
let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined));
|
||||
let captureError: ExecutionError | undefined;
|
||||
|
||||
const appendFullOutput = (text: string): void => {
|
||||
if (!fullOutputPath || captureError) return;
|
||||
const path = fullOutputPath;
|
||||
if (!fullOutputRequested || captureError) return;
|
||||
writeChain = writeChain.then(async (previous) => {
|
||||
if (!previous.ok) return previous;
|
||||
const appendResult = await env.appendFile(path, text, options?.abortSignal);
|
||||
if (!fullOutputPath) return err(new ExecutionError("unknown", "Full output path was not created"));
|
||||
const appendResult = await env.appendFile(fullOutputPath, text);
|
||||
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
|
||||
});
|
||||
};
|
||||
|
||||
const ensureFullOutputFile = (initialContent: string): void => {
|
||||
if (fullOutputPath || captureError) return;
|
||||
if (fullOutputRequested || captureError) return;
|
||||
fullOutputRequested = true;
|
||||
writeChain = writeChain.then(async (previous) => {
|
||||
if (!previous.ok) return previous;
|
||||
const tempFile = await env.createTempFile({
|
||||
prefix: "bash-",
|
||||
suffix: ".log",
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
const tempFile = await env.createTempFile({ prefix: "bash-", suffix: ".log" });
|
||||
if (!tempFile.ok) return err(toExecutionError(tempFile.error));
|
||||
fullOutputPath = tempFile.value;
|
||||
const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal);
|
||||
const appendResult = await env.appendFile(tempFile.value, initialContent);
|
||||
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
|
||||
});
|
||||
};
|
||||
|
||||
const onChunk = (chunk: string) => {
|
||||
const createProgress = (): ShellCaptureProgress => {
|
||||
const tailTruncation = truncateTail(tailOutput);
|
||||
const totalLines = completedLines + (hasOpenLine ? 1 : 0);
|
||||
const truncated = totalLines > DEFAULT_MAX_LINES || totalBytes > DEFAULT_MAX_BYTES;
|
||||
const truncation: TruncationResult = {
|
||||
...tailTruncation,
|
||||
truncated,
|
||||
truncatedBy: truncated
|
||||
? (tailTruncation.truncatedBy ?? (totalBytes > DEFAULT_MAX_BYTES ? "bytes" : "lines"))
|
||||
: null,
|
||||
totalLines,
|
||||
totalBytes,
|
||||
};
|
||||
return {
|
||||
output: truncated ? truncation.content : tailOutput,
|
||||
truncation,
|
||||
fullOutputPath,
|
||||
lastLineBytes: currentLineBytes,
|
||||
};
|
||||
};
|
||||
|
||||
const onChunk = (chunk: string): void => {
|
||||
if (!acceptingOutput) return;
|
||||
try {
|
||||
totalBytes += encoder.encode(chunk).byteLength;
|
||||
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
|
||||
if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) {
|
||||
ensureFullOutputFile(outputChunks.join("") + text);
|
||||
} else {
|
||||
const textBytes = encoder.encode(text).byteLength;
|
||||
totalBytes += textBytes;
|
||||
const newlineCount = text.split("\n").length - 1;
|
||||
completedLines += newlineCount;
|
||||
const lastNewline = text.lastIndexOf("\n");
|
||||
if (lastNewline >= 0) {
|
||||
const trailingText = text.slice(lastNewline + 1);
|
||||
currentLineBytes = encoder.encode(trailingText).byteLength;
|
||||
hasOpenLine = trailingText.length > 0;
|
||||
} else if (text.length > 0) {
|
||||
currentLineBytes += textBytes;
|
||||
hasOpenLine = true;
|
||||
}
|
||||
|
||||
tailOutput += text;
|
||||
const totalLines = completedLines + (hasOpenLine ? 1 : 0);
|
||||
if ((totalBytes > DEFAULT_MAX_BYTES || totalLines > DEFAULT_MAX_LINES) && !fullOutputRequested) {
|
||||
ensureFullOutputFile(tailOutput);
|
||||
} else if (fullOutputRequested) {
|
||||
appendFullOutput(text);
|
||||
}
|
||||
outputChunks.push(text);
|
||||
outputBytes += text.length;
|
||||
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
|
||||
const removed = outputChunks.shift()!;
|
||||
outputBytes -= removed.length;
|
||||
}
|
||||
options?.onChunk?.(text);
|
||||
tailOutput = trimToLastUtf8Bytes(tailOutput, maxOutputBytes, encoder);
|
||||
options?.onChunk?.(text, createProgress);
|
||||
} catch (error) {
|
||||
captureError = toExecutionError(error);
|
||||
}
|
||||
@@ -96,40 +145,51 @@ export async function executeShellWithCapture(
|
||||
|
||||
try {
|
||||
const result = await env.exec(command, {
|
||||
...(options ?? {}),
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
inheritEnv: options?.inheritEnv,
|
||||
timeout: options?.timeout,
|
||||
abortSignal: options?.abortSignal,
|
||||
onStdout: onChunk,
|
||||
onStderr: onChunk,
|
||||
});
|
||||
const tailOutput = outputChunks.join("");
|
||||
const truncationResult = truncateTail(tailOutput);
|
||||
if (truncationResult.truncated && !fullOutputPath) {
|
||||
ensureFullOutputFile(tailOutput);
|
||||
}
|
||||
acceptingOutput = false;
|
||||
let progress = createProgress();
|
||||
if (progress.truncation.truncated && !fullOutputRequested) ensureFullOutputFile(tailOutput);
|
||||
const writeResult = await writeChain;
|
||||
if (!writeResult.ok) return err(writeResult.error);
|
||||
if (captureError) return err(captureError);
|
||||
progress = createProgress();
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
|
||||
return ok({
|
||||
output: truncationResult.truncated ? truncationResult.content : tailOutput,
|
||||
...progress,
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
truncated: truncationResult.truncated,
|
||||
fullOutputPath,
|
||||
truncated: progress.truncation.truncated,
|
||||
});
|
||||
}
|
||||
if (options?.returnExecutionErrors) {
|
||||
return ok({
|
||||
...progress,
|
||||
exitCode: undefined,
|
||||
cancelled: false,
|
||||
truncated: progress.truncation.truncated,
|
||||
executionError: result.error,
|
||||
});
|
||||
}
|
||||
return err(result.error);
|
||||
}
|
||||
const cancelled = options?.abortSignal?.aborted ?? false;
|
||||
return ok({
|
||||
output: truncationResult.truncated ? truncationResult.content : tailOutput,
|
||||
...progress,
|
||||
exitCode: cancelled ? undefined : result.value.exitCode,
|
||||
cancelled,
|
||||
truncated: truncationResult.truncated,
|
||||
fullOutputPath,
|
||||
truncated: progress.truncation.truncated,
|
||||
});
|
||||
} catch (error) {
|
||||
acceptingOutput = false;
|
||||
return err(toExecutionError(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,13 @@ function utf8ByteLength(content: string): number {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function splitLinesForCounting(content: string): string[] {
|
||||
if (content.length === 0) return [];
|
||||
const lines = content.split("\n");
|
||||
if (content.endsWith("\n")) lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function replaceUnpairedSurrogates(content: string): string {
|
||||
let output = "";
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
@@ -127,7 +134,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const totalBytes = utf8ByteLength(content);
|
||||
const lines = content.split("\n");
|
||||
const lines = splitLinesForCounting(content);
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Check if no truncation needed
|
||||
@@ -217,8 +224,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const totalBytes = utf8ByteLength(content);
|
||||
const lines = content.split("\n");
|
||||
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||||
const lines = splitLinesForCounting(content);
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Check if no truncation needed
|
||||
|
||||
@@ -37,6 +37,7 @@ export * from "./harness/session/repo-utils.ts";
|
||||
export * from "./harness/session/session.ts";
|
||||
export * from "./harness/skills.ts";
|
||||
export * from "./harness/system-prompt.ts";
|
||||
export * from "./harness/tools/index.ts";
|
||||
// Harness
|
||||
export * from "./harness/types.ts";
|
||||
export * from "./harness/utils/shell-output.ts";
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AgentHarness } from "../../src/harness/agent-harness.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||
import { Session } from "../../src/harness/session/session.ts";
|
||||
import type { AgentHarnessOptions } from "../../src/harness/types.ts";
|
||||
import { calculateTool } from "../utils/calculate.ts";
|
||||
|
||||
/** Shared collection; each faux provider gets a unique id so coexisting fakes route correctly. */
|
||||
@@ -23,7 +23,7 @@ function newFaux(): FauxProviderHandle {
|
||||
return faux;
|
||||
}
|
||||
|
||||
function createHarness(options: ConstructorParameters<typeof AgentHarness>[0]): AgentHarness {
|
||||
function createHarness(options: AgentHarnessOptions): AgentHarness {
|
||||
return new AgentHarness(options);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ describe("AgentHarness stream configuration", () => {
|
||||
const session = new Session(new InMemorySessionStorage({ metadata: { id: "session-1", createdAt: "now" } }));
|
||||
const harness = createHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
streamOptions: {
|
||||
@@ -98,7 +97,6 @@ describe("AgentHarness stream configuration", () => {
|
||||
|
||||
const harness = createHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
streamOptions: {
|
||||
@@ -156,7 +154,6 @@ describe("AgentHarness stream configuration", () => {
|
||||
|
||||
const harness = createHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
tools: [calculateTool],
|
||||
@@ -191,7 +188,6 @@ describe("AgentHarness stream configuration", () => {
|
||||
|
||||
const harness = createHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AgentHarness } from "../../src/harness/agent-harness.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||
import { Session } from "../../src/harness/session/session.ts";
|
||||
import type { PromptTemplate, Skill } from "../../src/harness/types.ts";
|
||||
import type { AgentHarnessTool, PromptTemplate, Skill } from "../../src/harness/types.ts";
|
||||
import type { AgentMessage, AgentTool } from "../../src/types.ts";
|
||||
import { calculateTool, createCalculateToolWithUsage } from "../utils/calculate.ts";
|
||||
import { getCurrentTimeTool } from "../utils/get-current-time.ts";
|
||||
@@ -92,11 +92,9 @@ function createAssistantMessage(text: string): AgentMessage {
|
||||
describe("AgentHarness", () => {
|
||||
it("constructs directly and exposes queue modes", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const initialModel = getModel("anthropic", "claude-sonnet-4-5");
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env,
|
||||
session,
|
||||
model: initialModel,
|
||||
thinkingLevel: "high",
|
||||
@@ -104,7 +102,6 @@ describe("AgentHarness", () => {
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
});
|
||||
expect(harness.env).toBe(env);
|
||||
expect(harness.getModel()).toBe(initialModel);
|
||||
expect(harness.getThinkingLevel()).toBe("high");
|
||||
expect(harness.getSteeringMode()).toBe("all");
|
||||
@@ -134,7 +131,6 @@ describe("AgentHarness", () => {
|
||||
]);
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
steeringMode: "one-at-a-time",
|
||||
@@ -170,7 +166,6 @@ describe("AgentHarness", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -211,7 +206,6 @@ describe("AgentHarness", () => {
|
||||
]);
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -264,7 +258,6 @@ describe("AgentHarness", () => {
|
||||
]);
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
followUpMode: "one-at-a-time",
|
||||
@@ -294,7 +287,6 @@ describe("AgentHarness", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -351,9 +343,8 @@ describe("AgentHarness", () => {
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
const harness = new AgentHarness<Skill, PromptTemplate, AgentTool>({
|
||||
const harness = new AgentHarness<undefined, Skill, PromptTemplate, AgentTool>({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
thinkingLevel: "off",
|
||||
@@ -390,7 +381,6 @@ describe("AgentHarness", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -421,7 +411,6 @@ describe("AgentHarness", () => {
|
||||
const barrier = deferred();
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -461,7 +450,6 @@ describe("AgentHarness", () => {
|
||||
const calculateToolWithUsage = createCalculateToolWithUsage(toolUsage);
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
tools: [calculateToolWithUsage],
|
||||
@@ -502,6 +490,75 @@ describe("AgentHarness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes a static application context to harness tools", async () => {
|
||||
const registration = newFaux();
|
||||
registration.setResponses([
|
||||
() =>
|
||||
fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-1" }), {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
]);
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const toolContext = { env };
|
||||
let receivedContext: typeof toolContext | undefined;
|
||||
const contextTool: AgentHarnessTool<typeof toolContext, typeof calculateTool.parameters, undefined> = {
|
||||
...calculateTool,
|
||||
name: "context",
|
||||
execute: async (toolCallId, params, signal, onUpdate, context) => {
|
||||
receivedContext = context;
|
||||
return { ...(await calculateTool.execute(toolCallId, params, signal, onUpdate)), terminate: true };
|
||||
},
|
||||
};
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
tools: [contextTool],
|
||||
toolContext,
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(receivedContext).toBe(toolContext);
|
||||
});
|
||||
|
||||
it("resolves async tool context providers for each turn snapshot", async () => {
|
||||
const registration = newFaux();
|
||||
registration.setResponses([
|
||||
() =>
|
||||
fauxAssistantMessage(fauxToolCall("context", { expression: "1 + 1" }, { id: "call-1" }), {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
() =>
|
||||
fauxAssistantMessage(fauxToolCall("context", { expression: "2 + 2" }, { id: "call-2" }), {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
() => fauxAssistantMessage("done"),
|
||||
]);
|
||||
type ToolContext = { generation: number };
|
||||
const generations: number[] = [];
|
||||
const contextTool: AgentHarnessTool<ToolContext, typeof calculateTool.parameters, undefined> = {
|
||||
...calculateTool,
|
||||
name: "context",
|
||||
execute: async (toolCallId, params, signal, onUpdate, context) => {
|
||||
generations.push(context.generation);
|
||||
return await calculateTool.execute(toolCallId, params, signal, onUpdate);
|
||||
},
|
||||
};
|
||||
let generation = 0;
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
tools: [contextTool],
|
||||
toolContext: async (): Promise<ToolContext> => ({ generation: ++generation }),
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(generations).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("persists generated compaction usage", async () => {
|
||||
const registration = newFaux();
|
||||
registration.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
|
||||
@@ -510,7 +567,6 @@ describe("AgentHarness", () => {
|
||||
await session.appendMessage(createAssistantMessage("two"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -530,7 +586,6 @@ describe("AgentHarness", () => {
|
||||
await session.appendMessage(createAssistantMessage("two"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -569,7 +624,6 @@ describe("AgentHarness", () => {
|
||||
await session.appendMessage(createAssistantMessage("two"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
|
||||
@@ -610,7 +664,6 @@ describe("AgentHarness", () => {
|
||||
await session.appendMessage(createAssistantMessage("two"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
|
||||
@@ -646,7 +699,6 @@ describe("AgentHarness", () => {
|
||||
await session.appendMessage(createAssistantMessage("two"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 },
|
||||
@@ -696,7 +748,6 @@ describe("AgentHarness", () => {
|
||||
await session.appendMessage(createAssistantMessage("abandoned reply"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
|
||||
@@ -734,7 +785,6 @@ describe("AgentHarness", () => {
|
||||
await session.appendMessage(createAssistantMessage("abandoned reply"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -754,7 +804,6 @@ describe("AgentHarness", () => {
|
||||
await session.appendMessage(createAssistantMessage("abandoned reply"));
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
@@ -769,14 +818,12 @@ describe("AgentHarness", () => {
|
||||
|
||||
it("preserves app tool types for getters and update events", async () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
type AppTool = AgentTool<typeof calculateTool.parameters, undefined> & { source: "builtin" | "extension" };
|
||||
const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" };
|
||||
const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" };
|
||||
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({
|
||||
const harness = new AgentHarness<undefined, AppSkill, AppPromptTemplate, AppTool>({
|
||||
models,
|
||||
env,
|
||||
session,
|
||||
model,
|
||||
tools: [inspectTool, searchTool],
|
||||
@@ -841,16 +888,14 @@ describe("AgentHarness", () => {
|
||||
|
||||
it("validates constructor tool names", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
expect(
|
||||
() => new AgentHarness({ env, session, models, model, tools: [calculateTool], activeToolNames: ["missing"] }),
|
||||
() => new AgentHarness({ session, models, model, tools: [calculateTool], activeToolNames: ["missing"] }),
|
||||
).toThrow(/Unknown tool/);
|
||||
expect(
|
||||
() =>
|
||||
new AgentHarness({
|
||||
models,
|
||||
env,
|
||||
session,
|
||||
model,
|
||||
tools: [calculateTool, calculateTool],
|
||||
@@ -861,7 +906,6 @@ describe("AgentHarness", () => {
|
||||
() =>
|
||||
new AgentHarness({
|
||||
models,
|
||||
env,
|
||||
session,
|
||||
model,
|
||||
tools: [calculateTool],
|
||||
@@ -872,9 +916,12 @@ describe("AgentHarness", () => {
|
||||
|
||||
it("preserves app resource types for getters and update events", async () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AgentTool>({ env, session, models, model });
|
||||
const harness = new AgentHarness<undefined, AppSkill, AppPromptTemplate, AgentTool>({
|
||||
session,
|
||||
models,
|
||||
model,
|
||||
});
|
||||
const skill: AppSkill = {
|
||||
name: "inspect",
|
||||
description: "Inspect things",
|
||||
|
||||
@@ -605,6 +605,9 @@ describe("harness compaction", () => {
|
||||
getOrThrow(await compact(preparation, models, model));
|
||||
|
||||
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
|
||||
expect(seenOptions.map((options) => options?.cacheRetention)).toEqual(["none", "none"]);
|
||||
const sessionIds = seenOptions.map((options) => options?.sessionId);
|
||||
expect(sessionIds[0]).not.toBe(sessionIds[1]);
|
||||
});
|
||||
|
||||
it("returns compaction error results without throwing", async () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { access, chmod, realpath, symlink } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { FileError, getOrThrow } from "../../src/harness/types.ts";
|
||||
@@ -8,6 +12,52 @@ import { createTempDir } from "./session-test-utils.ts";
|
||||
|
||||
const chmodRestorePaths: string[] = [];
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout?: () => void): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
onTimeout?.();
|
||||
reject(new Error(`Timed out after ${ms}ms`));
|
||||
}, ms);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function toBashSingleQuotedArg(value: string): string {
|
||||
return `'${value.replace(/\\/g, "/").replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function createInheritedStdioCommand(pidFile: string): string {
|
||||
return (
|
||||
'node -e "' +
|
||||
"const fs=require('fs');" +
|
||||
"const {spawn}=require('child_process');" +
|
||||
"const child=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'inherit',detached:true});" +
|
||||
"fs.writeFileSync(process.argv[1], String(child.pid));" +
|
||||
"child.unref();" +
|
||||
"console.log('child-exiting');" +
|
||||
'" ' +
|
||||
toBashSingleQuotedArg(pidFile)
|
||||
);
|
||||
}
|
||||
|
||||
function cleanupDetachedChild(pidFile: string): void {
|
||||
if (!existsSync(pidFile)) return;
|
||||
const pid = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
||||
if (!Number.isFinite(pid) || pid <= 0) return;
|
||||
try {
|
||||
execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const path of chmodRestorePaths.splice(0)) {
|
||||
try {
|
||||
@@ -45,6 +95,14 @@ describe("NodeExecutionEnv", () => {
|
||||
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("expands home-relative paths and file URLs", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
expect(getOrThrow(await env.absolutePath("~/pi-node-env-test"))).toBe(join(homedir(), "pi-node-env-test"));
|
||||
const filePath = join(root, "file with spaces.txt");
|
||||
expect(getOrThrow(await env.absolutePath(pathToFileURL(filePath).href))).toBe(filePath);
|
||||
});
|
||||
|
||||
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
@@ -201,6 +259,29 @@ describe("NodeExecutionEnv", () => {
|
||||
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
|
||||
});
|
||||
|
||||
it("can replace rather than inherit the default shell environment", async () => {
|
||||
const root = createTempDir();
|
||||
const inheritedKey = "PI_NODE_ENV_INHERITED_TEST";
|
||||
const configuredKey = "PI_NODE_ENV_CONFIGURED_TEST";
|
||||
const explicitKey = "PI_NODE_ENV_EXPLICIT_TEST";
|
||||
const previousInherited = process.env[inheritedKey];
|
||||
process.env[inheritedKey] = "host";
|
||||
try {
|
||||
const env = new NodeExecutionEnv({ cwd: root, shellEnv: { [configuredKey]: "configured" } });
|
||||
const result = getOrThrow(
|
||||
await env.exec(`printf '%s:%s:%s' "\${${inheritedKey}-}" "\${${configuredKey}-}" "\${${explicitKey}-}"`, {
|
||||
inheritEnv: false,
|
||||
env: { [explicitKey]: "explicit" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.stdout).toBe("::explicit");
|
||||
} finally {
|
||||
if (previousInherited === undefined) delete process.env[inheritedKey];
|
||||
else process.env[inheritedKey] = previousInherited;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses stdin command transport for legacy WSL bash paths", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const root = createTempDir();
|
||||
@@ -234,6 +315,41 @@ describe("NodeExecutionEnv", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"settles after the shell exits when a detached descendant retains inherited stdio",
|
||||
async () => {
|
||||
const root = createTempDir();
|
||||
const pidFile = join(root, "grandchild.pid");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const result = getOrThrow(
|
||||
await withTimeout(
|
||||
env.exec(createInheritedStdioCommand(pidFile), { abortSignal: controller.signal }),
|
||||
3000,
|
||||
() => controller.abort(),
|
||||
),
|
||||
);
|
||||
expect(result.stdout).toContain("child-exiting");
|
||||
} finally {
|
||||
controller.abort();
|
||||
cleanupDetachedChild(pidFile);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("cleanup terminates active shell processes", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const execution = env.exec("touch started; sleep 60");
|
||||
for (let attempt = 0; attempt < 100 && !getOrThrow(await env.exists("started")); attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
expect(getOrThrow(await env.exists("started"))).toBe(true);
|
||||
await env.cleanup();
|
||||
await expect(withTimeout(execution, 3000)).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("streams stdout and stderr chunks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
@@ -254,6 +370,17 @@ describe("NodeExecutionEnv", () => {
|
||||
expect(stderr).toBe("err");
|
||||
});
|
||||
|
||||
it("reports a missing working directory before spawning", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: join(root, "missing") });
|
||||
const result = await env.exec("printf ok");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "spawn_error", message: expect.stringContaining("Working directory does not exist") },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns non-zero command exit codes as successful execution results", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Api, Model, Models } from "@earendil-works/pi-ai";
|
||||
import { AgentHarness } from "../../src/harness/agent-harness.ts";
|
||||
import { createReadTool } from "../../src/harness/tools/read.ts";
|
||||
import type { ExecutionToolContext } from "../../src/harness/tools/tool-context.ts";
|
||||
import type { Session } from "../../src/harness/types.ts";
|
||||
|
||||
declare const models: Models;
|
||||
declare const model: Model<Api>;
|
||||
declare const session: Session;
|
||||
declare const toolContext: ExecutionToolContext;
|
||||
|
||||
const readTool = createReadTool();
|
||||
|
||||
new AgentHarness({ models, model, session, tools: [readTool], toolContext });
|
||||
|
||||
// @ts-expect-error Context-requiring tools must be paired with toolContext.
|
||||
new AgentHarness({ models, model, session, tools: [readTool] });
|
||||
@@ -0,0 +1,610 @@
|
||||
import { symlink } from "node:fs/promises";
|
||||
import { applyPatch } from "diff";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { type BashToolDetails, createBashTool } from "../../src/harness/tools/bash.ts";
|
||||
import { createEditTool } from "../../src/harness/tools/edit.ts";
|
||||
import { createReadTool } from "../../src/harness/tools/read.ts";
|
||||
import { createWriteTool } from "../../src/harness/tools/write.ts";
|
||||
import {
|
||||
type ExecutionError,
|
||||
type FileError,
|
||||
getOrThrow,
|
||||
ok,
|
||||
type Result,
|
||||
type ShellExecOptions,
|
||||
} from "../../src/harness/types.ts";
|
||||
import { createTempDir } from "./session-test-utils.ts";
|
||||
|
||||
function textOutput(result: { content: Array<{ type: string; text?: string }> }): string {
|
||||
return result.content.flatMap((part) => (part.type === "text" ? [part.text ?? ""] : [])).join("\n");
|
||||
}
|
||||
|
||||
function createContext() {
|
||||
const env = new NodeExecutionEnv({ cwd: createTempDir() });
|
||||
return { env };
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve = () => {};
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
class SlowReadExecutionEnv extends NodeExecutionEnv {
|
||||
override async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
|
||||
await delay(20);
|
||||
return super.readTextFile(path, abortSignal);
|
||||
}
|
||||
}
|
||||
|
||||
class BlockingWriteExecutionEnv extends NodeExecutionEnv {
|
||||
readonly firstWriteStarted = deferred();
|
||||
readonly finishFirstWrite = deferred();
|
||||
secondWriteStarted = false;
|
||||
|
||||
override async writeFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<void, FileError>> {
|
||||
if (content === "first\n") {
|
||||
this.firstWriteStarted.resolve();
|
||||
await this.finishFirstWrite.promise;
|
||||
} else if (content === "second\n") {
|
||||
this.secondWriteStarted = true;
|
||||
}
|
||||
return super.writeFile(path, content, abortSignal);
|
||||
}
|
||||
}
|
||||
|
||||
class BlockingEditExecutionEnv extends NodeExecutionEnv {
|
||||
readonly firstEditWriteStarted = deferred();
|
||||
readonly finishFirstEditWrite = deferred();
|
||||
firstEditWriteSettled = false;
|
||||
secondEditWriteStarted = false;
|
||||
|
||||
override async writeFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<void, FileError>> {
|
||||
if (content === "ALPHA\nbeta\n") {
|
||||
this.firstEditWriteStarted.resolve();
|
||||
await this.finishFirstEditWrite.promise;
|
||||
const result = await super.writeFile(path, content);
|
||||
this.firstEditWriteSettled = true;
|
||||
return result;
|
||||
}
|
||||
if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") {
|
||||
this.secondEditWriteStarted = true;
|
||||
}
|
||||
return super.writeFile(path, content, abortSignal);
|
||||
}
|
||||
}
|
||||
|
||||
class LateOutputExecutionEnv extends NodeExecutionEnv {
|
||||
override async exec(
|
||||
_command: string,
|
||||
options?: ShellExecOptions,
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||
options?.onStdout?.("before\n");
|
||||
setTimeout(() => options?.onStdout?.("late\n"), 0);
|
||||
return ok({ stdout: "before\n", stderr: "", exitCode: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function createTinyBmp(): Uint8Array {
|
||||
const bytes = new Uint8Array(58);
|
||||
const view = new DataView(bytes.buffer);
|
||||
bytes[0] = 0x42;
|
||||
bytes[1] = 0x4d;
|
||||
view.setUint32(2, bytes.length, true);
|
||||
view.setUint32(10, 54, true);
|
||||
view.setUint32(14, 40, true);
|
||||
view.setInt32(18, 1, true);
|
||||
view.setInt32(22, 1, true);
|
||||
view.setUint16(26, 1, true);
|
||||
view.setUint16(28, 24, true);
|
||||
view.setUint32(34, 4, true);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
describe("AgentHarness tools", () => {
|
||||
describe("read", () => {
|
||||
it("reads text with offsets, limits, and continuation notices", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(
|
||||
await context.env.writeFile(
|
||||
"test.txt",
|
||||
Array.from({ length: 100 }, (_, index) => `Line ${index + 1}`).join("\n"),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await createReadTool().execute(
|
||||
"read-1",
|
||||
{ path: "test.txt", offset: 41, limit: 20 },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
const output = textOutput(result);
|
||||
|
||||
expect(output).not.toContain("Line 40");
|
||||
expect(output).toContain("Line 41");
|
||||
expect(output).toContain("Line 60");
|
||||
expect(output).not.toContain("Line 61");
|
||||
expect(output).toContain("[40 more lines in file. Use offset=61 to continue.]");
|
||||
});
|
||||
|
||||
it("truncates large text by line count", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(
|
||||
await context.env.writeFile(
|
||||
"large.txt",
|
||||
Array.from({ length: 2500 }, (_, index) => `Line ${index + 1}`).join("\n"),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await createReadTool().execute("read-2", { path: "large.txt" }, undefined, undefined, context);
|
||||
|
||||
expect(textOutput(result)).toContain("[Showing lines 1-2000 of 2500. Use offset=2001 to continue.]");
|
||||
expect(result.details?.truncation).toMatchObject({
|
||||
truncated: true,
|
||||
truncatedBy: "lines",
|
||||
totalLines: 2500,
|
||||
outputLines: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count a trailing newline as an extra line at the truncation limit", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(
|
||||
await context.env.writeFile("exact.txt", `${Array.from({ length: 2000 }, () => "x").join("\n")}\n`),
|
||||
);
|
||||
|
||||
const result = await createReadTool().execute(
|
||||
"read-exact",
|
||||
{ path: "exact.txt" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(result.details).toBeUndefined();
|
||||
expect(textOutput(result)).not.toContain("Use offset=");
|
||||
});
|
||||
|
||||
it("rejects offsets beyond the file", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("short.txt", "one\ntwo\nthree"));
|
||||
|
||||
await expect(
|
||||
createReadTool().execute("read-3", { path: "short.txt", offset: 100 }, undefined, undefined, context),
|
||||
).rejects.toThrow("Offset 100 is beyond end of file (3 lines total)");
|
||||
});
|
||||
|
||||
it("detects supported images by content", async () => {
|
||||
const context = createContext();
|
||||
const png = Uint8Array.from(
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
getOrThrow(await context.env.writeFile("image.txt", png));
|
||||
|
||||
const result = await createReadTool().execute("read-4", { path: "image.txt" }, undefined, undefined, context);
|
||||
|
||||
expect(textOutput(result)).toContain("Read image file [image/png]");
|
||||
expect(result.content).toContainEqual({
|
||||
type: "image",
|
||||
data: Buffer.from(png).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates image conversion and resizing to an injected processor", async () => {
|
||||
const context = createContext();
|
||||
const bmp = createTinyBmp();
|
||||
getOrThrow(await context.env.writeFile("image.bmp", bmp));
|
||||
let received: { bytes: Uint8Array; mimeType: string; autoResizeImages: boolean } | undefined;
|
||||
const tool = createReadTool({
|
||||
autoResizeImages: false,
|
||||
imageProcessor: async (bytes, mimeType, options) => {
|
||||
received = { bytes, mimeType, autoResizeImages: options.autoResizeImages };
|
||||
return {
|
||||
ok: true,
|
||||
data: "converted",
|
||||
mimeType: "image/png",
|
||||
hints: ["[Image converted from image/bmp to image/png.]"],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("read-bmp", { path: "image.bmp" }, undefined, undefined, context);
|
||||
|
||||
expect(received).toMatchObject({ mimeType: "image/bmp", autoResizeImages: false });
|
||||
expect(Array.from(received?.bytes ?? [])).toEqual(Array.from(bmp));
|
||||
expect(textOutput(result)).toContain("[Image converted from image/bmp to image/png.]");
|
||||
expect(result.content).toContainEqual({ type: "image", data: "converted", mimeType: "image/png" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("write", () => {
|
||||
it("writes files and creates parent directories", async () => {
|
||||
const context = createContext();
|
||||
const result = await createWriteTool().execute(
|
||||
"write-1",
|
||||
{ path: "nested/dir/file.txt", content: "hello" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toBe("Successfully wrote 5 bytes to nested/dir/file.txt");
|
||||
expect(getOrThrow(await context.env.readTextFile("nested/dir/file.txt"))).toBe("hello");
|
||||
});
|
||||
|
||||
it("keeps the mutation queue locked until an aborted write settles", async () => {
|
||||
const env = new BlockingWriteExecutionEnv({ cwd: createTempDir() });
|
||||
const tool = createWriteTool();
|
||||
const controller = new AbortController();
|
||||
const firstWrite = tool.execute(
|
||||
"write-first",
|
||||
{ path: "file.txt", content: "first\n" },
|
||||
controller.signal,
|
||||
undefined,
|
||||
{
|
||||
env,
|
||||
},
|
||||
);
|
||||
await env.firstWriteStarted.promise;
|
||||
controller.abort();
|
||||
const secondWrite = tool.execute(
|
||||
"write-second",
|
||||
{ path: "file.txt", content: "second\n" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
);
|
||||
|
||||
await delay(20);
|
||||
expect(env.secondWriteStarted).toBe(false);
|
||||
env.finishFirstWrite.resolve();
|
||||
await expect(firstWrite).rejects.toThrow();
|
||||
await secondWrite;
|
||||
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("second\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit", () => {
|
||||
it("applies disjoint edits and returns both diff formats", async () => {
|
||||
const context = createContext();
|
||||
const original = "alpha\nbeta\ngamma\ndelta\n";
|
||||
getOrThrow(await context.env.writeFile("edit.txt", original));
|
||||
|
||||
const result = await createEditTool().execute(
|
||||
"edit-1",
|
||||
{
|
||||
path: "edit.txt",
|
||||
edits: [
|
||||
{ oldText: "alpha\n", newText: "ALPHA\n" },
|
||||
{ oldText: "gamma\n", newText: "GAMMA\n" },
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toBe("Successfully replaced 2 block(s) in edit.txt.");
|
||||
expect(result.details?.diff).toContain("ALPHA");
|
||||
expect(result.details?.diff).toContain("GAMMA");
|
||||
expect(applyPatch(original, result.details?.patch ?? "")).toBe("ALPHA\nbeta\nGAMMA\ndelta\n");
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("ALPHA\nbeta\nGAMMA\ndelta\n");
|
||||
});
|
||||
|
||||
it("matches all edits against the original and rejects overlaps", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "one\ntwo\nthree\n"));
|
||||
|
||||
await expect(
|
||||
createEditTool().execute(
|
||||
"edit-2",
|
||||
{
|
||||
path: "edit.txt",
|
||||
edits: [
|
||||
{ oldText: "one\ntwo\n", newText: "ONE\nTWO\n" },
|
||||
{ oldText: "two\nthree\n", newText: "TWO\nTHREE\n" },
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow(/overlap/);
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("one\ntwo\nthree\n");
|
||||
});
|
||||
|
||||
it("rejects missing and duplicate target text", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "foo foo foo"));
|
||||
const tool = createEditTool();
|
||||
|
||||
await expect(
|
||||
tool.execute(
|
||||
"edit-3",
|
||||
{ path: "edit.txt", edits: [{ oldText: "bar", newText: "baz" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow(/Could not find the exact text/);
|
||||
await expect(
|
||||
tool.execute(
|
||||
"edit-4",
|
||||
{ path: "edit.txt", edits: [{ oldText: "foo", newText: "bar" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow(/Found 3 occurrences/);
|
||||
});
|
||||
|
||||
it("keeps the mutation queue locked until an aborted edit write settles", async () => {
|
||||
const env = new BlockingEditExecutionEnv({ cwd: createTempDir() });
|
||||
getOrThrow(await env.writeFile("file.txt", "alpha\nbeta\n"));
|
||||
const tool = createEditTool();
|
||||
const controller = new AbortController();
|
||||
const firstEdit = tool.execute(
|
||||
"edit-first",
|
||||
{ path: "file.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
|
||||
controller.signal,
|
||||
undefined,
|
||||
{ env },
|
||||
);
|
||||
await env.firstEditWriteStarted.promise;
|
||||
controller.abort();
|
||||
const secondEdit = tool.execute(
|
||||
"edit-second",
|
||||
{ path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
);
|
||||
|
||||
await delay(20);
|
||||
expect(env.secondEditWriteStarted).toBe(false);
|
||||
env.finishFirstEditWrite.resolve();
|
||||
await expect(firstEdit).rejects.toThrow("Operation aborted");
|
||||
await secondEdit;
|
||||
expect(env.firstEditWriteSettled).toBe(true);
|
||||
expect(getOrThrow(await env.readTextFile("file.txt"))).toBe("ALPHA\nBETA\n");
|
||||
});
|
||||
|
||||
it("serializes concurrent edits through canonical and symlink paths", async () => {
|
||||
const env = new SlowReadExecutionEnv({ cwd: createTempDir() });
|
||||
getOrThrow(await env.writeFile("target.txt", "alpha\nbeta\ngamma\n"));
|
||||
await symlink("target.txt", `${env.cwd}/link.txt`);
|
||||
const tool = createEditTool();
|
||||
|
||||
await Promise.all([
|
||||
tool.execute(
|
||||
"edit-target",
|
||||
{ path: "target.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
),
|
||||
tool.execute(
|
||||
"edit-link",
|
||||
{ path: "link.txt", edits: [{ oldText: "beta", newText: "BETA" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
{ env },
|
||||
),
|
||||
]);
|
||||
|
||||
expect(getOrThrow(await env.readTextFile("target.txt"))).toBe("ALPHA\nBETA\ngamma\n");
|
||||
});
|
||||
|
||||
it("edits regular files through symlinks", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("target.txt", "before\n"));
|
||||
await symlink("target.txt", `${context.env.cwd}/link.txt`);
|
||||
|
||||
await createEditTool().execute(
|
||||
"edit-symlink",
|
||||
{ path: "link.txt", edits: [{ oldText: "before", newText: "after" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(getOrThrow(await context.env.readTextFile("target.txt"))).toBe("after\n");
|
||||
});
|
||||
|
||||
it("preserves BOM and CRLF line endings", async () => {
|
||||
const context = createContext();
|
||||
getOrThrow(await context.env.writeFile("edit.txt", "\uFEFFone\r\ntwo\r\n"));
|
||||
|
||||
await createEditTool().execute(
|
||||
"edit-5",
|
||||
{ path: "edit.txt", edits: [{ oldText: "two", newText: "TWO" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(getOrThrow(await context.env.readTextFile("edit.txt"))).toBe("\uFEFFone\r\nTWO\r\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bash", () => {
|
||||
it("executes commands and combines stdout and stderr", async () => {
|
||||
const context = createContext();
|
||||
const result = await createBashTool().execute(
|
||||
"bash-1",
|
||||
{ command: "printf out; printf err >&2" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toContain("out");
|
||||
expect(textOutput(result)).toContain("err");
|
||||
});
|
||||
|
||||
it("reports nonzero exits and timeouts", async () => {
|
||||
const context = createContext();
|
||||
const tool = createBashTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("bash-2", { command: "printf failed; exit 7" }, undefined, undefined, context),
|
||||
).rejects.toThrow(/failed[\s\S]*Command exited with code 7/);
|
||||
await expect(
|
||||
tool.execute("bash-3", { command: "sleep 2", timeout: 0.01 }, undefined, undefined, context),
|
||||
).rejects.toThrow(/Command timed out after 0.01 seconds/);
|
||||
});
|
||||
|
||||
it("preserves truncated output when a command times out", async () => {
|
||||
const context = createContext();
|
||||
let error: unknown;
|
||||
try {
|
||||
await createBashTool().execute(
|
||||
"bash-timeout-output",
|
||||
{
|
||||
command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done; sleep 2",
|
||||
timeout: 0.05,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
} catch (cause) {
|
||||
error = cause;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const message = (error as Error).message;
|
||||
expect(message).toContain("Command timed out after 0.05 seconds");
|
||||
const fullOutputPath = message.match(/Full output: ([^\]\n]+)/)?.[1];
|
||||
expect(fullOutputPath).toBeDefined();
|
||||
const fullOutput = getOrThrow(await context.env.readTextFile(fullOutputPath!));
|
||||
expect(fullOutput).toContain("line-1\nline-2");
|
||||
expect(fullOutput).toContain("line-2999\nline-3000");
|
||||
});
|
||||
|
||||
it("ignores output callbacks after execution settles", async () => {
|
||||
const env = new LateOutputExecutionEnv({ cwd: createTempDir() });
|
||||
const updates: string[] = [];
|
||||
const result = await createBashTool().execute(
|
||||
"bash-late",
|
||||
{ command: "late" },
|
||||
undefined,
|
||||
(update) => updates.push(textOutput(update)),
|
||||
{ env },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(textOutput(result)).toBe("before\n");
|
||||
expect(updates.some((update) => update.includes("late"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the total size of an oversized final line", async () => {
|
||||
const context = createContext();
|
||||
const result = await createBashTool().execute(
|
||||
"bash-long-line",
|
||||
{ command: "printf '%060000d' 0" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toMatch(/Showing last 50\.0KB of line 1 \(line is 58\.6KB\)\. Full output:/);
|
||||
});
|
||||
|
||||
it("prepares command, cwd, and an explicit environment with the turn context", async () => {
|
||||
const env = new NodeExecutionEnv({
|
||||
cwd: createTempDir(),
|
||||
shellEnv: { PI_BASH_PREPARE_INHERITED: "inherited" },
|
||||
});
|
||||
getOrThrow(await env.createDir("workspace"));
|
||||
const context = { env, workspace: `${env.cwd}/workspace` };
|
||||
const controller = new AbortController();
|
||||
let receivedContext: typeof context | undefined;
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const tool = createBashTool<typeof context>({
|
||||
commandPrefix: "prefix=ready",
|
||||
prepare: async (execution, turnContext, signal) => {
|
||||
receivedContext = turnContext;
|
||||
receivedSignal = signal;
|
||||
execution.cwd = turnContext.workspace;
|
||||
execution.env = { PI_BASH_PREPARE_EXPLICIT: "explicit" };
|
||||
execution.inheritEnv = false;
|
||||
execution.command += `\nprintf '%s:%s:%s:%s' "$prefix" "\${PI_BASH_PREPARE_INHERITED-}" "$PI_BASH_PREPARE_EXPLICIT" "$PWD"`;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("bash-prepare", { command: ":" }, controller.signal, undefined, context);
|
||||
|
||||
expect(receivedContext).toBe(context);
|
||||
expect(receivedSignal).toBe(controller.signal);
|
||||
expect(textOutput(result)).toBe(`ready::explicit:${getOrThrow(await env.canonicalPath(context.workspace))}`);
|
||||
});
|
||||
|
||||
it("supports command prefixes", async () => {
|
||||
const context = createContext();
|
||||
const result = await createBashTool({ commandPrefix: "value=hello" }).execute(
|
||||
"bash-4",
|
||||
{ command: "printf $value" },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(textOutput(result)).toBe("hello");
|
||||
});
|
||||
|
||||
it("coalesces updates and persists truncated full output", async () => {
|
||||
const context = createContext();
|
||||
const updates: Array<{
|
||||
content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
|
||||
details?: BashToolDetails;
|
||||
}> = [];
|
||||
const result = await createBashTool().execute(
|
||||
"bash-5",
|
||||
{ command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done" },
|
||||
undefined,
|
||||
(update) => updates.push(update),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(updates.length).toBeLessThan(25);
|
||||
expect(result.details?.truncation).toMatchObject({
|
||||
truncated: true,
|
||||
truncatedBy: "lines",
|
||||
totalLines: 3000,
|
||||
outputLines: 2000,
|
||||
});
|
||||
expect(textOutput(result)).toContain("line-3000");
|
||||
expect(result.details?.fullOutputPath).toBeDefined();
|
||||
const finalUpdate = updates.at(-1);
|
||||
expect(finalUpdate ? textOutput(finalUpdate) : "").toContain("line-3000");
|
||||
expect(finalUpdate?.details).toMatchObject({
|
||||
truncation: { totalLines: 3000, totalBytes: expect.any(Number) },
|
||||
fullOutputPath: result.details?.fullOutputPath,
|
||||
});
|
||||
const fullOutput = getOrThrow(await context.env.readTextFile(result.details!.fullOutputPath!));
|
||||
expect(fullOutput).toContain("line-1\nline-2");
|
||||
expect(fullOutput).toContain("line-2999\nline-3000");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,15 @@ describe("truncate utilities", () => {
|
||||
expect(result.totalBytes).toBe(9);
|
||||
});
|
||||
|
||||
it("does not count a trailing newline as an extra line", () => {
|
||||
const content = `${Array.from({ length: 3 }, () => "line").join("\n")}\n`;
|
||||
const head = truncateHead(content, { maxBytes: 100, maxLines: 3 });
|
||||
const tail = truncateTail(content, { maxBytes: 100, maxLines: 3 });
|
||||
|
||||
expect(head).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 });
|
||||
expect(tail).toMatchObject({ truncated: false, totalLines: 3, outputLines: 3 });
|
||||
});
|
||||
|
||||
it("truncates head on UTF-8 byte limits without partial lines", () => {
|
||||
const content = "éé\nabc";
|
||||
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
|
||||
|
||||
@@ -7,6 +7,10 @@ import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||
import {
|
||||
AgentHarness,
|
||||
createBashTool,
|
||||
createEditTool,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
formatSkillsForSystemPrompt,
|
||||
loadSourcedPromptTemplates,
|
||||
loadSourcedSkills,
|
||||
@@ -49,12 +53,13 @@ if (!model) {
|
||||
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const agent = new AgentHarness({
|
||||
env,
|
||||
session,
|
||||
models,
|
||||
model,
|
||||
thinkingLevel: "low",
|
||||
systemPrompt: ({ env, resources }) =>
|
||||
tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()],
|
||||
toolContext: { env },
|
||||
systemPrompt: ({ resources }) =>
|
||||
[
|
||||
"You are a helpful assistant.",
|
||||
formatSkillsForSystemPrompt(resources.skills ?? []),
|
||||
@@ -68,5 +73,7 @@ const agent = new AgentHarness({
|
||||
},
|
||||
});
|
||||
|
||||
const response = await agent.prompt("What skills do you have? Any duplicates?");
|
||||
const response = await agent.prompt(
|
||||
"What skills do you have? Any duplicates? Also use bash to get the current date and time, then read README.md and tell me what this project is about.",
|
||||
);
|
||||
console.log(response);
|
||||
|
||||
@@ -2,8 +2,44 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.82.1] - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added `ModelsStoreEntry.etag` so persisted provider catalogs can carry the remote ETag validator for conditional refreshes.
|
||||
- Added `ANTHROPIC_AUTH_TOKEN` bearer authentication for Anthropic-compatible gateways ([#5871](https://github.com/earendil-works/pi/issues/5871))
|
||||
- Added Claude Opus 5 support for Anthropic and Amazon Bedrock with adaptive thinking, inference profiles, prompt caching, and preserved AWS validation messages ([#7081](https://github.com/earendil-works/pi/pull/7081) by [@unexge](https://github.com/unexge), [#7083](https://github.com/earendil-works/pi/pull/7083) by [@davidbrai](https://github.com/davidbrai)).
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed Radius OAuth device authorization, token exchange, and refresh requests to use the configured gateway directly.
|
||||
- Changed `ModelsError` messages to append the underlying cause, so auth failures such as `OAuth refresh failed for openai-codex` report the provider response instead of a bare wrapper message.
|
||||
|
||||
## [0.82.0] - 2026-07-24
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Replaced `getBuiltinModelDataUrl(provider)` with `getBuiltinModelDataGeneratedAt()` so built-in catalog freshness uses its recorded generation time instead of installation-dependent file metadata ([#7016](https://github.com/earendil-works/pi/pull/7016) by [@davidbrai](https://github.com/davidbrai)).
|
||||
|
||||
### Added
|
||||
|
||||
- Added Kimi Code subscription OAuth login for the `kimi-coding` provider, with device authorization, token refresh, and OAuth host overrides ([#6935](https://github.com/earendil-works/pi/pull/6935) by [@zaycruz](https://github.com/zaycruz)).
|
||||
- Added OpenRouter OAuth PKCE login that mints a user-controlled API key for chat and image providers ([#6927](https://github.com/earendil-works/pi/pull/6927) by [@rsaryev](https://github.com/rsaryev)).
|
||||
- Added `Tool.constrainedSampling` with strict JSON Schema (`prefer`/`require`) and OpenAI Lark/regex grammar variants, enforcing provider-side constrained tool sampling across OpenAI, Anthropic, Amazon Bedrock, Google Gemini, and Mistral. See [Constrained Sampling for Tools](README.md#constrained-sampling-for-tools).
|
||||
- Added `supportsGrammarTools` and `supportsStrictTools` compatibility flags, expanded `supportsStrictMode` to Responses and Bedrock models, and generated model capability metadata to gate constrained sampling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed generated model catalogs to expose only provider-verified reasoning effort levels from models.dev ([#6928](https://github.com/earendil-works/pi/pull/6928) by [@davidbrai](https://github.com/davidbrai)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Codex cached WebSocket continuations after grammar tool calls to send only the real tool-result delta.
|
||||
- Fixed constrained tool sampling across Google, Amazon Bedrock, Mistral, and Azure OpenAI Responses adapters, including model-aware strict-tool capabilities, grammar configuration validation, and malformed grammar-call replay errors.
|
||||
- Fixed `cacheRetention: "none"` to disable implicit prompt-cache writes for supported OpenAI models and session-based caching for OpenAI Codex ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)).
|
||||
- Fixed DNS lookup failures such as `getaddrinfo`, `ENOTFOUND`, and `EAI_AGAIN` to trigger automatic assistant retries ([#6946](https://github.com/earendil-works/pi/pull/6946) by [@christianklotz](https://github.com/christianklotz)).
|
||||
- Fixed OpenAI Codex WebSocket sessions to retry once without a missing previous-response continuation after `previous_response_not_found` errors ([#6955](https://github.com/earendil-works/pi/pull/6955) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Fixed OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)).
|
||||
- Fixed OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)).
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
@@ -24,7 +60,6 @@
|
||||
- Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)).
|
||||
- Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
|
||||
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Added Kimi Code subscription OAuth login (device authorization grant) for the `kimi-coding` provider, with token refresh and `KIMI_CODE_OAUTH_HOST`/`KIMI_OAUTH_HOST` host overrides.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -167,6 +202,7 @@
|
||||
### Added
|
||||
|
||||
- Added OpenAI GPT-5.6 model metadata for `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`, plus verified `openai-codex` support for `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`.
|
||||
- Added provider-side constrained sampling for tools via `Tool.constrainedSampling`: strict JSON-schema enforcement for OpenAI and Anthropic tool calls, and OpenAI custom grammar tools (Lark/regex). Grammar tool capability comes from the model catalog's `supportsGrammarTools` compat flag, enabled for GPT-5+ models on OpenAI, OpenAI Codex, Azure OpenAI, GitHub Copilot, opencode, and Cloudflare AI Gateway ([#6341](https://github.com/earendil-works/pi/pull/6341)).
|
||||
- Refreshed generated model catalogs from models.dev, adding newly listed models including Kimi K2.7 Code for GitHub Copilot and Fable 5 to several providers ([#6256](https://github.com/earendil-works/pi/issues/6256)).
|
||||
- Added Claude Sonnet 5 to the GitHub Copilot model catalog ([#6200](https://github.com/earendil-works/pi/issues/6200)).
|
||||
- Added zstd request-body compression for the OpenAI Codex Responses SSE transport. Requests are sent with `Content-Encoding: zstd` when Node/Bun zstd support is available; the WebSocket transport is unchanged.
|
||||
|
||||
+42
-4
@@ -478,6 +478,40 @@ const bookMeetingTool: Tool = {
|
||||
};
|
||||
```
|
||||
|
||||
### Constrained Sampling for Tools
|
||||
|
||||
Tools can opt in to provider-side constrained sampling. For JSON-schema tools, `strict: 'prefer'` uses provider-side strict schema enforcement when supported and otherwise falls back to normal tool calling. `strict: 'require'` fails the request when the active provider/model cannot honor it. Set `constrainedSampling: false` to explicitly opt out; it behaves the same as omitting the field.
|
||||
|
||||
```typescript
|
||||
const strictTool: Tool = {
|
||||
name: 'edit_file',
|
||||
description: 'Edit a file',
|
||||
parameters: Type.Object({
|
||||
path: Type.String(),
|
||||
content: Type.String()
|
||||
}, { additionalProperties: false }),
|
||||
constrainedSampling: { type: 'json_schema', strict: 'prefer' }
|
||||
};
|
||||
```
|
||||
|
||||
Strict JSON-schema constrained sampling is supported for OpenAI, Anthropic, supported Amazon Bedrock Converse models, Mistral, and Gemini 3 tool calls through the Google Generative AI and Vertex adapters. Google uses `VALIDATED` function-calling mode (or `ANY` when explicitly requested); earlier Gemini versions fall back for `strict: 'prefer'` and reject `strict: 'require'` because they do not enforce required parameters. Bedrock strict-tool capability is generated from model structured-output metadata; custom Bedrock models can override `compat.supportsStrictMode`. OpenAI Responses and Chat Completions can also emit grammar-constrained custom tools with OpenAI Lark or regex grammar variants. If multiple OpenAI variants are supplied, Lark is preferred over regex. Grammar constraints are enforced when the active model supports grammar tools; otherwise the tool falls back to normal function/JSON-schema handling. Grammar tool capability is model metadata: the generated catalog sets `compat.supportsOpenAIGrammarTools` for GPT-5+ models on endpoints that pass OpenAI custom tools through (OpenAI, OpenAI Codex, Azure OpenAI Responses, GitHub Copilot, opencode, and Cloudflare AI Gateway). OpenAI rejects `type: "custom"` tools for pre-GPT-5 models, and gateways that normalize tool schemas (e.g. OpenRouter) mangle them, so the flag stays off elsewhere. Custom model definitions can opt in via `compat`. Grammar-capable models reject grammar configurations without a non-empty supported variant. Native grammar tools must have an object parameter schema with exactly one required string property:
|
||||
|
||||
```typescript
|
||||
const patchTool: Tool = {
|
||||
name: 'apply_patch',
|
||||
description: 'Apply a patch',
|
||||
parameters: Type.Object({
|
||||
input: Type.String()
|
||||
}, { additionalProperties: false }),
|
||||
constrainedSampling: {
|
||||
type: 'grammar',
|
||||
variants: {
|
||||
openai_lark: 'start: /.+/s'
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Handling Tool Calls
|
||||
|
||||
Tool results use content blocks and can include both text and images:
|
||||
@@ -1124,6 +1158,7 @@ interface OpenAICompletionsCompat {
|
||||
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
|
||||
supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (default: true)
|
||||
supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: true)
|
||||
supportsOpenAIGrammarTools?: boolean; // Whether to emit OpenAI custom Lark/regex grammar tools; false falls back to normal function tools (default: false; the generated catalog enables it for capable models)
|
||||
sendSessionAffinityHeaders?: boolean; // Send session-affinity data from `sessionId` (default: false)
|
||||
sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Format for session affinity: 'openai' uses `prompt_cache_key`, `session_id`, `x-client-request-id`, and `x-session-affinity`; 'openai-nosession' uses `prompt_cache_key`, `x-client-request-id`, and `x-session-affinity`; 'openrouter' uses `x-session-id` (default: auto-detected)
|
||||
maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens)
|
||||
@@ -1142,6 +1177,8 @@ interface OpenAIResponsesCompat {
|
||||
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
|
||||
sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Session-affinity header format: 'openai' sends `session_id` and `x-client-request-id`; 'openai-nosession' sends `x-client-request-id`; 'openrouter' sends `x-session-id`. Does not affect the `prompt_cache_key` body param (default: auto-detected)
|
||||
supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true)
|
||||
supportsStrictMode?: boolean; // Whether provider supports strict JSON-schema function tools (default: false; enabled in metadata for built-in OpenAI models)
|
||||
supportsOpenAIGrammarTools?: boolean; // Whether to emit OpenAI custom Lark/regex grammar tools; false falls back to normal function tools (default: false; the generated catalog enables it for capable models)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1422,8 +1459,9 @@ Several providers support OAuth authentication instead of static API keys:
|
||||
- **Anthropic** (Claude Pro/Max subscription)
|
||||
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
|
||||
- **GitHub Copilot** (Copilot subscription)
|
||||
- **OpenRouter** (OAuth PKCE that mints a user-controlled API key)
|
||||
|
||||
Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` exchanges the refresh token, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh.
|
||||
Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` refreshes expiring credentials when applicable, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh. OpenRouter's OAuth flow instead returns a permanent API key, so its refresh operation is a no-op.
|
||||
|
||||
```typescript
|
||||
import { createModels } from '@earendil-works/pi-ai';
|
||||
@@ -1499,7 +1537,7 @@ Built-in login and refresh flows are private provider implementations. Use provi
|
||||
|
||||
Provider notes:
|
||||
|
||||
**OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId`, connections are reused per session and expire after 5 minutes of inactivity.
|
||||
**OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options unless `cacheRetention` is `"none"`. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId` and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity.
|
||||
|
||||
**Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. `AZURE_OPENAI_BASE_URL` supports both `https://<resource>.openai.azure.com` and `https://<resource>.cognitiveservices.azure.com`; root endpoints are normalized to `.../openai/v1` automatically. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported.
|
||||
|
||||
@@ -1533,7 +1571,7 @@ Compat is a strict superset of the root entrypoint, so a file can switch its imp
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
Adding a new LLM provider requires changes across multiple files. The layered layout: API implementations live in `src/api/`, provider factories in `src/providers/`, generated catalogs in `src/providers/<id>.models.ts`. This checklist covers all necessary steps:
|
||||
Adding a new LLM provider requires changes across multiple files. The layered layout: API implementations live in `src/api/`, provider factories in `src/providers/`, stable generated catalog wrappers live in `src/providers/<id>.models.ts`, and `src/models.generated.ts` registers them. This checklist covers all necessary steps:
|
||||
|
||||
#### 1. Core Types (`src/types.ts`)
|
||||
|
||||
@@ -1555,7 +1593,7 @@ Add a lazy wrapper `src/api/<api-id>.lazy.ts` (`<name>Api()` via `lazyApi()`) so
|
||||
#### 3. Model Generation (`scripts/generate-models.ts`, `scripts/generate-image-models.ts`)
|
||||
|
||||
- Add logic to fetch and parse models from the provider's source (e.g., models.dev API)
|
||||
- Map chat/tool-capable provider model data to the standardized `Model` interface via `scripts/generate-models.ts`; regeneration emits structural `src/providers/<id>.models.ts` shards, ignored values in `src/providers/data/`, and the aggregator
|
||||
- Map chat/tool-capable provider model data to the standardized `Model` interface via `scripts/generate-models.ts`; hydration groups the ignored `src/providers/data/<id>.json` values by API, while stable `src/providers/<id>.models.ts` wrappers derive exact model/API types directly from those JSON keys
|
||||
- Map image-generation provider model data to the standardized `ImagesModel` interface via `scripts/generate-image-models.ts`
|
||||
- Handle provider-specific quirks (pricing format, capability flags, model ID transformations)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
createModelDataManifest,
|
||||
type ModelDataStructure,
|
||||
MODEL_DATA_MANIFEST_FILE,
|
||||
readModelDataStructure,
|
||||
readModelDataProviderIds,
|
||||
validateGeneratedModelData,
|
||||
validateModelDataDirectory,
|
||||
} from "./model-data.ts";
|
||||
@@ -83,6 +83,7 @@ interface ModelsDevModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tool_call?: boolean;
|
||||
structured_output?: boolean;
|
||||
reasoning?: boolean;
|
||||
reasoning_options?: ModelsDevReasoningOption[];
|
||||
limit?: {
|
||||
@@ -291,6 +292,7 @@ const ANT_LING_RING_THINKING_LEVEL_MAP = {
|
||||
xhigh: "xhigh",
|
||||
} as const;
|
||||
|
||||
const BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]);
|
||||
const MODELS_DEV_OPENAI_UNSUPPORTED_MODEL_IDS = new Set(["gpt-5.6"]);
|
||||
const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([
|
||||
"gpt-5.4",
|
||||
@@ -485,6 +487,8 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
|
||||
modelId.includes("opus-4.7") ||
|
||||
modelId.includes("opus-4-8") ||
|
||||
modelId.includes("opus-4.8") ||
|
||||
modelId.includes("opus-5") ||
|
||||
modelId.includes("opus.5") ||
|
||||
modelId.includes("sonnet-4-6") ||
|
||||
modelId.includes("sonnet-4.6") ||
|
||||
modelId.includes("sonnet-5") ||
|
||||
@@ -495,7 +499,14 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
|
||||
|
||||
function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean {
|
||||
const id = modelId.toLowerCase();
|
||||
return id.includes("opus-4-7") || id.includes("opus-4.7") || id.includes("opus-4-8") || id.includes("opus-4.8");
|
||||
return (
|
||||
id.includes("opus-4-7") ||
|
||||
id.includes("opus-4.7") ||
|
||||
id.includes("opus-4-8") ||
|
||||
id.includes("opus-4.8") ||
|
||||
id.includes("opus-5") ||
|
||||
id.includes("opus.5")
|
||||
);
|
||||
}
|
||||
|
||||
const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
|
||||
@@ -514,6 +525,7 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
|
||||
chatTemplateKwargs: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: true,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
sendSessionAffinityHeaders: false,
|
||||
supportsLongCacheRetention: true,
|
||||
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat" | "deferredToolsMode">> & {
|
||||
@@ -602,6 +614,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
|
||||
chatTemplateKwargs: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
...(cacheControlFormat ? { cacheControlFormat } : {}),
|
||||
sendSessionAffinityHeaders: false,
|
||||
supportsLongCacheRetention: !(
|
||||
@@ -643,6 +656,39 @@ function applyOpenAICompletionsCompatMetadata(model: Model<Api>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function applyStrictToolCompatMetadata(model: Model<Api>): void {
|
||||
if (model.provider === "openai" && model.api === "openai-responses") {
|
||||
model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsStrictMode: true };
|
||||
} else if (model.provider === "anthropic" && model.api === "anthropic-messages") {
|
||||
mergeAnthropicMessagesCompat(model, { supportsStrictTools: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Responses endpoints verified (OpenAI, ChatGPT Codex backend, GitHub Copilot,
|
||||
// opencode zen) or documented (Azure OpenAI, Cloudflare AI Gateway) to pass
|
||||
// OpenAI custom grammar tools through. OpenAI rejects `type: "custom"` tools
|
||||
// for pre-GPT-5 models (gpt-4.x, gpt-4o, o-series).
|
||||
const OPENAI_GRAMMAR_TOOL_PROVIDERS = new Set([
|
||||
"openai",
|
||||
"openai-codex",
|
||||
"azure-openai-responses",
|
||||
"github-copilot",
|
||||
"opencode",
|
||||
"cloudflare-ai-gateway",
|
||||
]);
|
||||
const OPENAI_GRAMMAR_TOOL_APIS = new Set<Api>([
|
||||
"openai-responses",
|
||||
"azure-openai-responses",
|
||||
"openai-codex-responses",
|
||||
]);
|
||||
|
||||
function applyOpenAIGrammarToolCompatMetadata(model: Model<Api>): void {
|
||||
if (!OPENAI_GRAMMAR_TOOL_APIS.has(model.api) || !OPENAI_GRAMMAR_TOOL_PROVIDERS.has(model.provider)) return;
|
||||
const match = /^gpt-(\d+)/.exec(model.id);
|
||||
if (!match || Number(match[1]) < 5) return;
|
||||
model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), supportsOpenAIGrammarTools: true };
|
||||
}
|
||||
|
||||
function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
|
||||
const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses";
|
||||
const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses";
|
||||
@@ -653,6 +699,18 @@ function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI charges prompt-cache writes starting with the GPT-5.6 family, and exactly
|
||||
// those models accept `prompt_cache_options`; older models reject the parameter.
|
||||
// https://developers.openai.com/api/docs/guides/prompt-caching
|
||||
function applyOpenAIExplicitPromptCacheMetadata(model: Model<Api>): void {
|
||||
if (model.provider !== "openai" || model.api !== "openai-responses") return;
|
||||
if (!(model.cost.cacheWrite > 0)) return;
|
||||
model.compat = {
|
||||
...(model.compat as OpenAIResponsesCompat | undefined),
|
||||
supportsExplicitPromptCacheMode: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isGemini3ProModel(modelId: string): boolean {
|
||||
return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase());
|
||||
}
|
||||
@@ -700,7 +758,7 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
|
||||
}
|
||||
// Anthropic adaptive-thinking effort support (per Anthropic adaptive thinking docs):
|
||||
// - "max" is available on all adaptive-thinking Claude models.
|
||||
// - "xhigh" is only available on Opus 4.7/4.8, Sonnet 5, and Fable 5.
|
||||
// - "xhigh" is only available on Opus 4.7/4.8/5, Sonnet 5, and Fable 5.
|
||||
if (
|
||||
model.id.includes("opus-4-6") ||
|
||||
model.id.includes("opus-4.6") ||
|
||||
@@ -714,6 +772,8 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
|
||||
model.id.includes("opus-4.7") ||
|
||||
model.id.includes("opus-4-8") ||
|
||||
model.id.includes("opus-4.8") ||
|
||||
model.id.includes("opus-5") ||
|
||||
model.id.includes("opus.5") ||
|
||||
model.id.includes("sonnet-5") ||
|
||||
model.id.includes("sonnet.5")
|
||||
) {
|
||||
@@ -1004,6 +1064,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
for (const [modelId, model] of Object.entries(data["amazon-bedrock"].models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
if (BEDROCK_INFERENCE_PROFILE_ONLY_MODEL_IDS.has(modelId)) continue;
|
||||
|
||||
let id = modelId;
|
||||
|
||||
@@ -1033,6 +1094,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
},
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
...(m.structured_output === true && { compat: { supportsStrictMode: true } }),
|
||||
});
|
||||
recordModelsDevReasoningOptions("amazon-bedrock" as const, id, m);
|
||||
}
|
||||
@@ -2455,7 +2517,10 @@ async function generateModels() {
|
||||
applyOpenAICompletionsCompatMetadata(model);
|
||||
applyModelsDevReasoningOptionMetadata(model);
|
||||
applyThinkingLevelMetadata(model);
|
||||
applyStrictToolCompatMetadata(model);
|
||||
applyOpenAIGrammarToolCompatMetadata(model);
|
||||
applyOpenAIToolSearchMetadata(model);
|
||||
applyOpenAIExplicitPromptCacheMetadata(model);
|
||||
}
|
||||
|
||||
// Group by provider and deduplicate by model ID
|
||||
@@ -2482,43 +2547,34 @@ async function generateModels() {
|
||||
|
||||
const serializeJson = (value: unknown) => `${JSON.stringify(value, null, generatorOptions.pretty ? 2 : undefined)}\n`;
|
||||
const writeJson = (path: string, value: unknown) => writeFileSync(path, serializeJson(value));
|
||||
let generatedDataProviderIds = sortedProviderIds;
|
||||
let generatedDataProviders = jsonProviders;
|
||||
let modelDataStructure: ModelDataStructure = Object.fromEntries(
|
||||
sortedProviderIds.map((providerId) => [
|
||||
providerId,
|
||||
Object.fromEntries(
|
||||
Object.entries(jsonProviders[providerId]).map(([modelId, model]) => [modelId, model.api]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const generatedDataProviderIds = generatorOptions.dataOnly
|
||||
? readModelDataProviderIds(packageRoot)
|
||||
: sortedProviderIds;
|
||||
const missingProviderIds = generatedDataProviderIds.filter((providerId) => !jsonProviders[providerId]);
|
||||
if (missingProviderIds.length > 0) {
|
||||
throw new Error(`Cannot hydrate missing providers: ${missingProviderIds.join(", ")}`);
|
||||
}
|
||||
|
||||
if (generatorOptions.dataOnly) {
|
||||
modelDataStructure = readModelDataStructure(packageRoot);
|
||||
generatedDataProviderIds = Object.keys(modelDataStructure);
|
||||
const hydratedProviders: typeof jsonProviders = {};
|
||||
const hydrationErrors: string[] = [];
|
||||
for (const [providerId, expectedModels] of Object.entries(modelDataStructure)) {
|
||||
hydratedProviders[providerId] = {};
|
||||
for (const [modelId, expectedApi] of Object.entries(expectedModels)) {
|
||||
const model = jsonProviders[providerId]?.[modelId];
|
||||
if (!model) {
|
||||
hydrationErrors.push(`missing ${providerId}/${modelId}`);
|
||||
continue;
|
||||
}
|
||||
if (model.api !== expectedApi) {
|
||||
hydrationErrors.push(`${providerId}/${modelId} uses ${model.api}, expected ${expectedApi}`);
|
||||
continue;
|
||||
}
|
||||
hydratedProviders[providerId][modelId] = model;
|
||||
// Only the ignored internal data is grouped by API for type derivation. Public JSON catalog output stays flat.
|
||||
const generatedDataProviders: Record<string, Record<string, Record<string, Model<Api>>>> = {};
|
||||
const modelDataStructure: ModelDataStructure = {};
|
||||
for (const providerId of generatedDataProviderIds) {
|
||||
const models = jsonProviders[providerId];
|
||||
generatedDataProviders[providerId] = {};
|
||||
modelDataStructure[providerId] = {};
|
||||
const apiIds = Array.from(new Set(Object.values(models).map((model) => model.api))).sort();
|
||||
for (const api of apiIds) {
|
||||
generatedDataProviders[providerId][api] = {};
|
||||
for (const [modelId, model] of Object.entries(models)) {
|
||||
if (model.api !== api) continue;
|
||||
generatedDataProviders[providerId][api][modelId] = model;
|
||||
modelDataStructure[providerId][modelId] = api;
|
||||
}
|
||||
}
|
||||
if (hydrationErrors.length > 0) {
|
||||
throw new Error(`Cannot hydrate the committed model catalog:\n${hydrationErrors.map((error) => ` - ${error}`).join("\n")}`);
|
||||
}
|
||||
generatedDataProviders = hydratedProviders;
|
||||
}
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
if (!generatorOptions.jsonOnly) {
|
||||
// Stage and validate all provider values before replacing the current generated data.
|
||||
const providersDir = join(packageRoot, "src/providers");
|
||||
@@ -2526,7 +2582,7 @@ async function generateModels() {
|
||||
const stagingRoot = mkdtempSync(join(providersDir, ".model-generation-"));
|
||||
const stagedDataDir = join(stagingRoot, "data");
|
||||
const previousDataDir = join(stagingRoot, "previous-data");
|
||||
let restoreStructuralCatalog: (() => void) | undefined;
|
||||
let restoreGeneratedCatalog: (() => void) | undefined;
|
||||
try {
|
||||
mkdirSync(stagedDataDir, { recursive: true });
|
||||
const fileContents: Record<string, string> = {};
|
||||
@@ -2538,12 +2594,11 @@ async function generateModels() {
|
||||
}
|
||||
writeJson(
|
||||
join(stagedDataDir, MODEL_DATA_MANIFEST_FILE),
|
||||
createModelDataManifest(modelDataStructure, fileContents),
|
||||
createModelDataManifest(modelDataStructure, fileContents, generatedAt),
|
||||
);
|
||||
validateModelDataDirectory(modelDataStructure, stagedDataDir);
|
||||
|
||||
if (!generatorOptions.dataOnly) {
|
||||
// Generate TypeScript structural catalogs only after the model data is complete and valid.
|
||||
const previousShardContents = new Map(
|
||||
readdirSync(providersDir)
|
||||
.filter((entry) => entry.endsWith(".models.ts"))
|
||||
@@ -2551,7 +2606,7 @@ async function generateModels() {
|
||||
);
|
||||
const aggregatorPath = join(packageRoot, "src/models.generated.ts");
|
||||
const previousAggregator = readFileSync(aggregatorPath, "utf8");
|
||||
restoreStructuralCatalog = () => {
|
||||
restoreGeneratedCatalog = () => {
|
||||
for (const entry of readdirSync(providersDir)) {
|
||||
if (entry.endsWith(".models.ts")) rmSync(join(providersDir, entry));
|
||||
}
|
||||
@@ -2568,21 +2623,12 @@ async function generateModels() {
|
||||
const catalogConstName = (providerId: string) =>
|
||||
`${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`;
|
||||
const generatedShardFiles = new Set<string>();
|
||||
|
||||
function emitModelShape(model: Model<any>, indent: string): string {
|
||||
return `${indent}${JSON.stringify(model.id)}: Model<${JSON.stringify(model.api)}> & {\n${indent}\tid: ${JSON.stringify(model.id)};\n${indent}\tprovider: ${JSON.stringify(model.provider)};\n${indent}};\n`;
|
||||
}
|
||||
|
||||
for (const providerId of sortedProviderIds) {
|
||||
const models = providers[providerId];
|
||||
let output = generatedHeader;
|
||||
output += `import values from "./data/${providerId}.json" with { type: "json" };\n`;
|
||||
output += `import type { Model } from "../types.ts";\n\n`;
|
||||
output += `export const ${catalogConstName(providerId)} = values as {\n`;
|
||||
for (const modelId of Object.keys(models).sort()) {
|
||||
output += emitModelShape(models[modelId], "\t");
|
||||
}
|
||||
output += `};\n`;
|
||||
output += `import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";\n\n`;
|
||||
output += `export const ${catalogConstName(providerId)}: ModelCatalog<typeof values, ${JSON.stringify(providerId)}> =\n`;
|
||||
output += `\tflattenModelCatalog(${JSON.stringify(providerId)}, values);\n`;
|
||||
const filename = `${providerId}.models.ts`;
|
||||
generatedShardFiles.add(filename);
|
||||
writeFileSync(join(providersDir, filename), output);
|
||||
@@ -2590,19 +2636,22 @@ async function generateModels() {
|
||||
for (const entry of readdirSync(providersDir)) {
|
||||
if (entry.endsWith(".models.ts") && !generatedShardFiles.has(entry)) rmSync(join(providersDir, entry));
|
||||
}
|
||||
console.log(`Generated ${sortedProviderIds.length} catalog structures under src/providers/`);
|
||||
|
||||
let output = generatedHeader;
|
||||
for (const providerId of sortedProviderIds) {
|
||||
output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`;
|
||||
}
|
||||
output += `\nexport const MODELS = {\n`;
|
||||
output += `\nexport const MODELS: {\n`;
|
||||
for (const providerId of sortedProviderIds) {
|
||||
output += `\treadonly ${JSON.stringify(providerId)}: typeof ${catalogConstName(providerId)};\n`;
|
||||
}
|
||||
output += `} = {\n`;
|
||||
for (const providerId of sortedProviderIds) {
|
||||
output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`;
|
||||
}
|
||||
output += `} as const;\n`;
|
||||
output += `};\n`;
|
||||
writeFileSync(aggregatorPath, output);
|
||||
console.log("Generated src/models.generated.ts");
|
||||
console.log("Generated provider catalogs and src/models.generated.ts");
|
||||
}
|
||||
|
||||
const hadPreviousData = existsSync(dataDir);
|
||||
@@ -2615,14 +2664,14 @@ async function generateModels() {
|
||||
if (hadPreviousData && existsSync(previousDataDir)) renameSync(previousDataDir, dataDir);
|
||||
throw error;
|
||||
}
|
||||
restoreStructuralCatalog = undefined;
|
||||
restoreGeneratedCatalog = undefined;
|
||||
console.log(
|
||||
generatorOptions.dataOnly
|
||||
? "Hydrated JSON model values under src/providers/data/"
|
||||
: "Generated JSON model values under src/providers/data/",
|
||||
);
|
||||
} catch (error) {
|
||||
restoreStructuralCatalog?.();
|
||||
restoreGeneratedCatalog?.();
|
||||
throw error;
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true });
|
||||
|
||||
@@ -2,32 +2,25 @@ import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const MODEL_DATA_SCHEMA_VERSION = 1;
|
||||
export const MODEL_DATA_SCHEMA_VERSION = 3;
|
||||
export const MODEL_DATA_MANIFEST_FILE = ".manifest.json";
|
||||
|
||||
export type ModelDataStructure = Record<string, Record<string, string>>;
|
||||
|
||||
export interface ModelDataManifest {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
structureHash: string;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
const JSON_STRING_PATTERN = '"(?:\\\\.|[^"\\\\])*"';
|
||||
const MODEL_SHAPE_PATTERN = new RegExp(`^\\t(${JSON_STRING_PATTERN}): Model<(${JSON_STRING_PATTERN})> & \\{$`);
|
||||
const MODEL_ID_PATTERN = new RegExp(`^\\t\\tid: (${JSON_STRING_PATTERN});$`);
|
||||
const MODEL_PROVIDER_PATTERN = new RegExp(`^\\t\\tprovider: (${JSON_STRING_PATTERN});$`);
|
||||
const MODEL_DATA_IMPORT_PATTERN =
|
||||
/^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function parseJsonString(value: string, description: string): string {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (typeof parsed !== "string") throw new Error(`${description} is not a string`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sortedRecord<T>(entries: Iterable<readonly [string, T]>): Record<string, T> {
|
||||
return Object.fromEntries(Array.from(entries).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||
}
|
||||
@@ -46,84 +39,6 @@ function describeSetDifference(expected: readonly string[], actual: readonly str
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function parseProviderStructure(path: string, providerId: string): Record<string, string> {
|
||||
const source = readFileSync(path, "utf8");
|
||||
const expectedImport = `import values from "./data/${providerId}.json" with { type: "json" };`;
|
||||
if (!source.includes(expectedImport)) {
|
||||
throw new Error(`${path} does not import ${providerId}.json`);
|
||||
}
|
||||
|
||||
const models = new Map<string, string>();
|
||||
const lines = source.split("\n");
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const shapeMatch = MODEL_SHAPE_PATTERN.exec(lines[index]);
|
||||
if (!shapeMatch) continue;
|
||||
|
||||
const idMatch = MODEL_ID_PATTERN.exec(lines[index + 1] ?? "");
|
||||
const providerMatch = MODEL_PROVIDER_PATTERN.exec(lines[index + 2] ?? "");
|
||||
if (!idMatch || !providerMatch || lines[index + 3] !== "\t};") {
|
||||
throw new Error(`${path}:${index + 1} has a malformed generated model declaration`);
|
||||
}
|
||||
|
||||
const key = parseJsonString(shapeMatch[1], `${path}:${index + 1} model key`);
|
||||
const api = parseJsonString(shapeMatch[2], `${path}:${index + 1} model API`);
|
||||
const id = parseJsonString(idMatch[1], `${path}:${index + 2} model ID`);
|
||||
const provider = parseJsonString(providerMatch[1], `${path}:${index + 3} provider ID`);
|
||||
if (id !== key) throw new Error(`${path}:${index + 1} declares key ${key} with ID ${id}`);
|
||||
if (provider !== providerId) {
|
||||
throw new Error(`${path}:${index + 1} declares provider ${provider} instead of ${providerId}`);
|
||||
}
|
||||
if (models.has(key)) throw new Error(`${path} declares model ${key} more than once`);
|
||||
models.set(key, api);
|
||||
index += 3;
|
||||
}
|
||||
|
||||
if (models.size === 0) throw new Error(`${path} contains no generated model declarations`);
|
||||
return sortedRecord(models);
|
||||
}
|
||||
|
||||
export function readModelDataStructure(packageRoot: string): ModelDataStructure {
|
||||
const providersDir = join(packageRoot, "src", "providers");
|
||||
const shardProviderIds = readdirSync(providersDir)
|
||||
.filter((entry) => entry.endsWith(".models.ts"))
|
||||
.map((entry) => entry.slice(0, -".models.ts".length))
|
||||
.sort();
|
||||
if (shardProviderIds.length === 0) throw new Error(`No generated provider shards found under ${providersDir}`);
|
||||
|
||||
const aggregator = readFileSync(join(packageRoot, "src", "models.generated.ts"), "utf8");
|
||||
const importedProviderIds = Array.from(
|
||||
aggregator.matchAll(/^import \{ [A-Z0-9_]+_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm),
|
||||
(match) => match[1],
|
||||
).sort();
|
||||
if (!sameStrings(shardProviderIds, importedProviderIds)) {
|
||||
throw new Error(
|
||||
`Generated model aggregator and provider shards do not match (${describeSetDifference(shardProviderIds, importedProviderIds)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return sortedRecord(
|
||||
shardProviderIds.map((providerId) => [
|
||||
providerId,
|
||||
parseProviderStructure(join(providersDir, `${providerId}.models.ts`), providerId),
|
||||
] as const),
|
||||
);
|
||||
}
|
||||
|
||||
export function modelDataStructureHash(structure: ModelDataStructure): string {
|
||||
return sha256(JSON.stringify(structure));
|
||||
}
|
||||
|
||||
export function createModelDataManifest(
|
||||
structure: ModelDataStructure,
|
||||
fileContents: Readonly<Record<string, string>>,
|
||||
): ModelDataManifest {
|
||||
return {
|
||||
schemaVersion: MODEL_DATA_SCHEMA_VERSION,
|
||||
structureHash: modelDataStructureHash(structure),
|
||||
files: sortedRecord(Object.entries(fileContents).map(([file, content]) => [file, sha256(content)] as const)),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -143,6 +58,78 @@ function readJsonObject(path: string, description: string, errors: string[]): Re
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readProviderStructure(path: string, providerId: string): Record<string, string> {
|
||||
const errors: string[] = [];
|
||||
const groups = readJsonObject(path, `${providerId}.json`, errors);
|
||||
if (!groups) throw new Error(errors.join("\n"));
|
||||
|
||||
const models = new Map<string, string>();
|
||||
for (const [api, value] of Object.entries(groups)) {
|
||||
if (!isRecord(value)) throw new Error(`${path} API group ${JSON.stringify(api)} must be an object`);
|
||||
for (const modelId of Object.keys(value)) {
|
||||
if (models.has(modelId)) throw new Error(`${path} contains model ${modelId} in more than one API group`);
|
||||
models.set(modelId, api);
|
||||
}
|
||||
}
|
||||
if (models.size === 0) throw new Error(`${path} contains no generated model data`);
|
||||
return sortedRecord(models);
|
||||
}
|
||||
|
||||
export function readModelDataProviderIds(packageRoot: string): string[] {
|
||||
const aggregatorPath = join(packageRoot, "src", "models.generated.ts");
|
||||
const aggregator = readFileSync(aggregatorPath, "utf8");
|
||||
const providerIds = Array.from(aggregator.matchAll(MODEL_DATA_IMPORT_PATTERN), (match) => match[1]).sort();
|
||||
if (providerIds.length === 0) throw new Error(`No generated provider imports found in ${aggregatorPath}`);
|
||||
if (new Set(providerIds).size !== providerIds.length) {
|
||||
throw new Error(`Generated model aggregator contains duplicate provider imports: ${aggregatorPath}`);
|
||||
}
|
||||
return providerIds;
|
||||
}
|
||||
|
||||
export function readModelDataStructure(packageRoot: string): ModelDataStructure {
|
||||
const providersDir = join(packageRoot, "src", "providers");
|
||||
const dataDir = join(providersDir, "data");
|
||||
const providerIds = readModelDataProviderIds(packageRoot);
|
||||
const expectedShards = providerIds.map((providerId) => `${providerId}.models.ts`).sort();
|
||||
const actualShards = readdirSync(providersDir)
|
||||
.filter((entry) => entry.endsWith(".models.ts"))
|
||||
.sort();
|
||||
if (!sameStrings(expectedShards, actualShards)) {
|
||||
throw new Error(
|
||||
`Generated model aggregator and provider shards do not match (${describeSetDifference(expectedShards, actualShards)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return sortedRecord(
|
||||
providerIds.map((providerId) => [
|
||||
providerId,
|
||||
readProviderStructure(join(dataDir, `${providerId}.json`), providerId),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function modelDataStructureHash(structure: ModelDataStructure): string {
|
||||
const normalized = sortedRecord(
|
||||
Object.entries(structure).map(
|
||||
([providerId, models]) => [providerId, sortedRecord(Object.entries(models))] as const,
|
||||
),
|
||||
);
|
||||
return sha256(JSON.stringify(normalized));
|
||||
}
|
||||
|
||||
export function createModelDataManifest(
|
||||
structure: ModelDataStructure,
|
||||
fileContents: Readonly<Record<string, string>>,
|
||||
generatedAt: string,
|
||||
): ModelDataManifest {
|
||||
return {
|
||||
schemaVersion: MODEL_DATA_SCHEMA_VERSION,
|
||||
generatedAt,
|
||||
structureHash: modelDataStructureHash(structure),
|
||||
files: sortedRecord(Object.entries(fileContents).map(([file, content]) => [file, sha256(content)] as const)),
|
||||
};
|
||||
}
|
||||
|
||||
function validateModelValue(
|
||||
value: unknown,
|
||||
providerId: string,
|
||||
@@ -209,7 +196,7 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
|
||||
.filter((entry) => entry.endsWith(".json") && entry !== MODEL_DATA_MANIFEST_FILE)
|
||||
.sort();
|
||||
if (!sameStrings(expectedFiles, actualFiles)) {
|
||||
errors.push(`provider data files do not match the structural catalog (${describeSetDifference(expectedFiles, actualFiles)})`);
|
||||
errors.push(`provider data files do not match the generated catalog (${describeSetDifference(expectedFiles, actualFiles)})`);
|
||||
}
|
||||
|
||||
const manifestPath = join(dataDir, MODEL_DATA_MANIFEST_FILE);
|
||||
@@ -219,9 +206,12 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
|
||||
`model data schema is ${JSON.stringify(manifest?.schemaVersion)}, expected ${MODEL_DATA_SCHEMA_VERSION}`,
|
||||
);
|
||||
}
|
||||
if (typeof manifest?.generatedAt !== "string" || Number.isNaN(Date.parse(manifest.generatedAt))) {
|
||||
errors.push("model data manifest has an invalid generation timestamp");
|
||||
}
|
||||
const expectedStructureHash = modelDataStructureHash(structure);
|
||||
if (manifest?.structureHash !== expectedStructureHash) {
|
||||
errors.push("model data generation stamp does not match the structural catalog");
|
||||
errors.push("model data generation stamp does not match the generated catalog");
|
||||
}
|
||||
const manifestFiles = isRecord(manifest?.files) ? manifest.files : undefined;
|
||||
if (!manifestFiles) errors.push("model data manifest has no file hashes");
|
||||
@@ -240,15 +230,37 @@ export function validateModelDataDirectory(structure: ModelDataStructure, dataDi
|
||||
if (manifestFiles && manifestFiles[filename] !== sha256(content)) {
|
||||
errors.push(`${filename} does not match its manifest hash`);
|
||||
}
|
||||
const values = readJsonObject(path, filename, errors);
|
||||
if (!values) continue;
|
||||
const expectedModelIds = Object.keys(expectedModels).sort();
|
||||
const actualModelIds = Object.keys(values).sort();
|
||||
if (!sameStrings(expectedModelIds, actualModelIds)) {
|
||||
errors.push(`${filename} model IDs do not match the structural catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`);
|
||||
const groups = readJsonObject(path, filename, errors);
|
||||
if (!groups) continue;
|
||||
|
||||
const actualModels = new Map<string, string>();
|
||||
for (const [api, value] of Object.entries(groups)) {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${filename} API group ${JSON.stringify(api)} must be an object`);
|
||||
continue;
|
||||
}
|
||||
for (const [modelId, model] of Object.entries(value)) {
|
||||
if (actualModels.has(modelId)) {
|
||||
errors.push(`${providerId}/${modelId} appears in more than one API group`);
|
||||
continue;
|
||||
}
|
||||
actualModels.set(modelId, api);
|
||||
validateModelValue(model, providerId, modelId, api, errors);
|
||||
}
|
||||
}
|
||||
for (const [modelId, api] of Object.entries(expectedModels)) {
|
||||
if (modelId in values) validateModelValue(values[modelId], providerId, modelId, api, errors);
|
||||
|
||||
const expectedModelIds = Object.keys(expectedModels).sort();
|
||||
const actualModelIds = Array.from(actualModels.keys()).sort();
|
||||
if (!sameStrings(expectedModelIds, actualModelIds)) {
|
||||
errors.push(`${filename} model IDs do not match the generated catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`);
|
||||
}
|
||||
for (const [modelId, expectedApi] of Object.entries(expectedModels)) {
|
||||
const actualApi = actualModels.get(modelId);
|
||||
if (actualApi !== undefined && actualApi !== expectedApi) {
|
||||
errors.push(
|
||||
`${providerId}/${modelId} is grouped under API ${JSON.stringify(actualApi)}, expected ${JSON.stringify(expectedApi)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
||||
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
@@ -178,6 +180,7 @@ function getAnthropicCompat(
|
||||
supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? true,
|
||||
supportsTemperature: model.compat?.supportsTemperature ?? true,
|
||||
allowEmptySignature: model.compat?.allowEmptySignature ?? false,
|
||||
supportsStrictTools: model.compat?.supportsStrictTools ?? false,
|
||||
supportsToolReferences: model.compat?.supportsToolReferences ?? defaultSupportsToolReferences(model),
|
||||
};
|
||||
}
|
||||
@@ -550,9 +553,16 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
||||
maxRetries: options?.maxRetries ?? 0,
|
||||
maxRetries: 0,
|
||||
};
|
||||
const response = await client.messages.create({ ...params, stream: true }, requestOptions).asResponse();
|
||||
const response = await retryProviderRequest(
|
||||
() => client.messages.create({ ...params, stream: true }, requestOptions).asResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
@@ -991,9 +1001,17 @@ function buildParams(
|
||||
immediateTools,
|
||||
isOAuthToken,
|
||||
compat.supportsEagerToolInputStreaming,
|
||||
compat.supportsStrictTools,
|
||||
compat.supportsCacheControlOnTools ? cacheControl : undefined,
|
||||
),
|
||||
...convertTools(deferredTools, isOAuthToken, compat.supportsEagerToolInputStreaming, undefined, true),
|
||||
...convertTools(
|
||||
deferredTools,
|
||||
isOAuthToken,
|
||||
compat.supportsEagerToolInputStreaming,
|
||||
compat.supportsStrictTools,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1261,23 +1279,34 @@ function convertTools(
|
||||
tools: Tool[],
|
||||
isOAuthToken: boolean,
|
||||
supportsEagerToolInputStreaming: boolean,
|
||||
supportsStrictTools: boolean,
|
||||
cacheControl?: CacheControlEphemeral,
|
||||
deferLoading = false,
|
||||
): Anthropic.Messages.Tool[] {
|
||||
if (!tools) return [];
|
||||
|
||||
return tools.map((tool, index) => {
|
||||
const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools);
|
||||
const schema = tool.parameters as { properties?: unknown; required?: string[] };
|
||||
const legacyInputSchema = {
|
||||
type: "object" as const,
|
||||
properties: schema.properties ?? {},
|
||||
required: schema.required ?? [],
|
||||
};
|
||||
const inputSchema =
|
||||
strict === true
|
||||
? {
|
||||
...(tool.parameters as Record<string, unknown>),
|
||||
...legacyInputSchema,
|
||||
}
|
||||
: legacyInputSchema;
|
||||
|
||||
return {
|
||||
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
|
||||
description: tool.description,
|
||||
...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}),
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
required: schema.required ?? [],
|
||||
},
|
||||
...(strict === true ? { strict: true } : {}),
|
||||
input_schema: inputSchema,
|
||||
...(deferLoading ? { defer_loading: true } : {}),
|
||||
...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
@@ -99,7 +101,11 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const client = createClient(model, apiKey, options);
|
||||
let params = buildParams(model, context, options, deploymentName);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
);
|
||||
let params = buildParams(model, context, options, deploymentName, grammarToolInputProperties);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as ResponseCreateParamsStreaming;
|
||||
@@ -107,13 +113,20 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
||||
maxRetries: options?.maxRetries ?? 0,
|
||||
maxRetries: 0,
|
||||
};
|
||||
const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse();
|
||||
const { data: openaiStream, response } = await retryProviderRequest(
|
||||
() => client.responses.create(params, requestOptions).withResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
await processResponsesStream(openaiStream, output, stream, model);
|
||||
await processResponsesStream(openaiStream, output, stream, model, { grammarToolInputProperties });
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
@@ -128,8 +141,9 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
delete (block as { index?: number }).index;
|
||||
// partialJson is only a streaming scratch buffer; never persist it.
|
||||
// Streaming scratch buffers are only used during parsing; never persist them.
|
||||
delete (block as { partialJson?: string }).partialJson;
|
||||
delete (block as { customInput?: unknown }).customInput;
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatAzureOpenAIError(error);
|
||||
@@ -254,8 +268,14 @@ function buildParams(
|
||||
context: Context,
|
||||
options: AzureOpenAIResponsesOptions | undefined,
|
||||
deploymentName: string,
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
),
|
||||
) {
|
||||
const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS);
|
||||
const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS, {
|
||||
grammarToolInputProperties,
|
||||
});
|
||||
|
||||
const params: ResponseCreateParamsStreaming = {
|
||||
model: deploymentName,
|
||||
@@ -274,7 +294,10 @@ function buildParams(
|
||||
}
|
||||
|
||||
if (context.tools && context.tools.length > 0) {
|
||||
params.tools = convertResponsesTools(context.tools);
|
||||
params.tools = convertResponsesTools(context.tools, {
|
||||
supportsStrictMode: model.compat?.supportsStrictMode ?? true,
|
||||
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
if (model.reasoning) {
|
||||
|
||||
@@ -54,6 +54,7 @@ import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import {
|
||||
adjustMaxTokensForThinking,
|
||||
buildBaseOptions,
|
||||
@@ -228,7 +229,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
|
||||
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
|
||||
...(options.temperature !== undefined && { temperature: options.temperature }),
|
||||
},
|
||||
toolConfig: convertToolConfig(context.tools, options.toolChoice),
|
||||
toolConfig: convertToolConfig(context.tools, options.toolChoice, model.compat?.supportsStrictMode ?? false),
|
||||
additionalModelRequestFields: buildAdditionalModelRequestFields(model, options),
|
||||
...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }),
|
||||
};
|
||||
@@ -581,6 +582,7 @@ function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean
|
||||
s.includes("opus-4-6") ||
|
||||
s.includes("opus-4-7") ||
|
||||
s.includes("opus-4-8") ||
|
||||
s.includes("opus-5") ||
|
||||
s.includes("sonnet-4-6") ||
|
||||
s.includes("sonnet-5") ||
|
||||
s.includes("fable-5"),
|
||||
@@ -590,7 +592,12 @@ function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean
|
||||
function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean {
|
||||
const candidates = getModelMatchCandidates(model.id, model.name);
|
||||
return candidates.some(
|
||||
(s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("sonnet-5") || s.includes("fable-5"),
|
||||
(s) =>
|
||||
s.includes("opus-4-7") ||
|
||||
s.includes("opus-4-8") ||
|
||||
s.includes("opus-5") ||
|
||||
s.includes("sonnet-5") ||
|
||||
s.includes("fable-5"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -669,8 +676,8 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: Pr
|
||||
if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true;
|
||||
return false;
|
||||
}
|
||||
// Claude 5 models (fable-5, sonnet-5)
|
||||
if (candidates.some((s) => s.includes("fable-5") || s.includes("sonnet-5"))) return true;
|
||||
// Claude 5 models (fable-5, opus-5, sonnet-5)
|
||||
if (candidates.some((s) => s.includes("fable-5") || s.includes("opus-5") || s.includes("sonnet-5"))) return true;
|
||||
// Claude 4.x models (opus-4, sonnet-4, haiku-4)
|
||||
if (candidates.some((s) => s.includes("-4-"))) return true;
|
||||
// Claude 3.7 Sonnet
|
||||
@@ -908,16 +915,22 @@ function convertMessages(
|
||||
function convertToolConfig(
|
||||
tools: Tool[] | undefined,
|
||||
toolChoice: BedrockOptions["toolChoice"],
|
||||
supportsStrictMode: boolean,
|
||||
): ToolConfiguration | undefined {
|
||||
if (!tools?.length || toolChoice === "none") return undefined;
|
||||
if (!tools?.length) return undefined;
|
||||
if (toolChoice === "none") return undefined;
|
||||
|
||||
const bedrockTools: BedrockTool[] = tools.map((tool) => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: { json: tool.parameters as unknown as DocumentType },
|
||||
},
|
||||
}));
|
||||
const bedrockTools: BedrockTool[] = tools.map((tool) => {
|
||||
const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
|
||||
return {
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: { json: tool.parameters as unknown as DocumentType },
|
||||
...(strict === true ? { strict: true } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let bedrockToolChoice: ToolChoice | undefined;
|
||||
switch (toolChoice) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Tool } from "../types.ts";
|
||||
|
||||
interface JsonSchemaObject {
|
||||
type?: unknown;
|
||||
properties?: Record<string, JsonSchemaObject | undefined>;
|
||||
required?: unknown;
|
||||
}
|
||||
|
||||
export interface GrammarConstrainedSampling {
|
||||
format: "lark" | "regex";
|
||||
definition: string;
|
||||
inputProperty: string;
|
||||
}
|
||||
|
||||
export interface GrammarToolInputJsonBuffer {
|
||||
input: string;
|
||||
started: boolean;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export function getGrammarToolInput(
|
||||
toolName: string,
|
||||
arguments_: Record<string, unknown>,
|
||||
inputProperty: string,
|
||||
): string {
|
||||
const input = arguments_[inputProperty];
|
||||
if (typeof input !== "string") {
|
||||
throw new Error(`Grammar tool call "${toolName}" requires argument "${inputProperty}" to be a string.`);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function appendGrammarToolInputJsonDelta(
|
||||
buffer: GrammarToolInputJsonBuffer,
|
||||
inputProperty: string,
|
||||
nextInput: string,
|
||||
close: boolean,
|
||||
): string | undefined {
|
||||
if (buffer.closed) {
|
||||
if (close && nextInput === buffer.input) return undefined;
|
||||
throw new Error(`grammar tool input for property "${inputProperty}" changed after it was closed`);
|
||||
}
|
||||
if (!nextInput.startsWith(buffer.input)) {
|
||||
throw new Error(`grammar tool input for property "${inputProperty}" changed non-monotonically`);
|
||||
}
|
||||
|
||||
const inputDelta = nextInput.slice(buffer.input.length);
|
||||
if (!close && inputDelta.length === 0) return undefined;
|
||||
|
||||
let delta = "";
|
||||
if (!buffer.started) {
|
||||
delta += `{${JSON.stringify(inputProperty)}:"`;
|
||||
buffer.started = true;
|
||||
}
|
||||
delta += JSON.stringify(inputDelta).slice(1, -1);
|
||||
buffer.input = nextInput;
|
||||
|
||||
if (close) {
|
||||
delta += '"}';
|
||||
buffer.closed = true;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
function inferGrammarInputProperty(tool: Tool): string {
|
||||
const schema = tool.parameters as JsonSchemaObject;
|
||||
if (schema.type !== "object") {
|
||||
throw new Error("grammar constrained sampling requires an object parameter schema");
|
||||
}
|
||||
if (!Array.isArray(schema.required) || schema.required.length !== 1 || typeof schema.required[0] !== "string") {
|
||||
throw new Error("grammar constrained sampling requires exactly one required string property");
|
||||
}
|
||||
|
||||
const inputProperty = schema.required[0];
|
||||
if (!schema.properties?.[inputProperty]) {
|
||||
throw new Error(`grammar constrained sampling requires a properties entry for ${inputProperty}`);
|
||||
}
|
||||
if (schema.properties[inputProperty]?.type !== "string") {
|
||||
throw new Error(`grammar constrained sampling property ${inputProperty} must have type string`);
|
||||
}
|
||||
return inputProperty;
|
||||
}
|
||||
|
||||
export function resolveJsonSchemaStrictSampling(tool: Tool, supportsStrictMode: boolean): boolean | undefined {
|
||||
const config = tool.constrainedSampling;
|
||||
if (!config || config.type !== "json_schema") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (supportsStrictMode) {
|
||||
return true;
|
||||
}
|
||||
if (config.strict === "require") {
|
||||
throw new Error(
|
||||
`Tool "${tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveGrammarConstrainedSampling(
|
||||
tool: Tool,
|
||||
supportsOpenAIGrammarTools: boolean,
|
||||
): GrammarConstrainedSampling | undefined {
|
||||
const config = tool.constrainedSampling;
|
||||
if (!config || config.type !== "grammar") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!supportsOpenAIGrammarTools) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const larkDefinition = config.variants.openai_lark;
|
||||
const regexDefinition = config.variants.openai_regex;
|
||||
const hasLarkDefinition = typeof larkDefinition === "string" && larkDefinition.trim().length > 0;
|
||||
const hasRegexDefinition = typeof regexDefinition === "string" && regexDefinition.trim().length > 0;
|
||||
if (!hasLarkDefinition && !hasRegexDefinition) {
|
||||
throw new Error(
|
||||
`Tool "${tool.name}" cannot use grammar constrained sampling: no supported grammar variant was provided.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
format: hasLarkDefinition ? "lark" : "regex",
|
||||
definition: hasLarkDefinition ? larkDefinition : regexDefinition!,
|
||||
inputProperty: inferGrammarInputProperty(tool),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Tool "${tool.name}" cannot use grammar constrained sampling: ${message}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createGrammarToolInputProperties(
|
||||
tools: Tool[] | undefined,
|
||||
supportsOpenAIGrammarTools: boolean,
|
||||
): ReadonlyMap<string, string> {
|
||||
const properties = new Map<string, string>();
|
||||
for (const tool of tools ?? []) {
|
||||
const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools);
|
||||
if (grammar) {
|
||||
properties.set(tool.name, grammar.inputProperty);
|
||||
}
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
@@ -30,8 +30,9 @@ import {
|
||||
convertTools,
|
||||
isThinkingPart,
|
||||
mapStopReason,
|
||||
mapToolChoice,
|
||||
resolveGoogleFunctionCallingMode,
|
||||
retainThoughtSignature,
|
||||
supportsGoogleStrictToolSampling,
|
||||
} from "./google-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
|
||||
@@ -355,22 +356,18 @@ function buildParams(
|
||||
generationConfig.maxOutputTokens = options.maxTokens;
|
||||
}
|
||||
|
||||
const functionCallingMode = context.tools?.length
|
||||
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsGoogleStrictToolSampling(model.id))
|
||||
: undefined;
|
||||
const config: GenerateContentConfig = {
|
||||
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
||||
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
|
||||
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
|
||||
...(functionCallingMode !== undefined && {
|
||||
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
|
||||
}),
|
||||
};
|
||||
|
||||
if (context.tools && context.tools.length > 0 && options.toolChoice) {
|
||||
config.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: mapToolChoice(options.toolChoice),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
config.toolConfig = undefined;
|
||||
}
|
||||
|
||||
if (options.thinking?.enabled && model.reasoning) {
|
||||
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
|
||||
if (options.thinking.level !== undefined) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
|
||||
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
type GoogleApiType = "google-generative-ai" | "google-vertex";
|
||||
@@ -287,9 +288,13 @@ export function convertTools(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map tool choice string to Gemini FunctionCallingConfigMode.
|
||||
*/
|
||||
/** Gemini 3+ enforces required function parameters in validated tool-calling modes. */
|
||||
export function supportsGoogleStrictToolSampling(modelId: string): boolean {
|
||||
const majorVersion = getGeminiMajorVersion(modelId);
|
||||
return majorVersion !== undefined && majorVersion >= 3;
|
||||
}
|
||||
|
||||
/** Map tool choice string to Gemini FunctionCallingConfigMode. */
|
||||
export function mapToolChoice(choice: string): FunctionCallingConfigMode {
|
||||
switch (choice) {
|
||||
case "auto":
|
||||
@@ -303,6 +308,21 @@ export function mapToolChoice(choice: string): FunctionCallingConfigMode {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGoogleFunctionCallingMode(
|
||||
tools: Tool[],
|
||||
toolChoice: string | undefined,
|
||||
supportsStrictMode: boolean,
|
||||
): FunctionCallingConfigMode | undefined {
|
||||
const useStrictMode = tools.some((tool) => resolveJsonSchemaStrictSampling(tool, supportsStrictMode) === true);
|
||||
if (toolChoice === "none" || toolChoice === "any") {
|
||||
return mapToolChoice(toolChoice);
|
||||
}
|
||||
if (useStrictMode) {
|
||||
return FunctionCallingConfigMode.VALIDATED;
|
||||
}
|
||||
return toolChoice ? mapToolChoice(toolChoice) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Gemini FinishReason to our StopReason.
|
||||
*/
|
||||
|
||||
@@ -35,8 +35,9 @@ import {
|
||||
convertTools,
|
||||
isThinkingPart,
|
||||
mapStopReason,
|
||||
mapToolChoice,
|
||||
resolveGoogleFunctionCallingMode,
|
||||
retainThoughtSignature,
|
||||
supportsGoogleStrictToolSampling,
|
||||
} from "./google-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
|
||||
@@ -454,22 +455,18 @@ function buildParams(
|
||||
generationConfig.maxOutputTokens = options.maxTokens;
|
||||
}
|
||||
|
||||
const functionCallingMode = context.tools?.length
|
||||
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsGoogleStrictToolSampling(model.id))
|
||||
: undefined;
|
||||
const config: GenerateContentConfig = {
|
||||
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
||||
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
|
||||
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
|
||||
...(functionCallingMode !== undefined && {
|
||||
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
|
||||
}),
|
||||
};
|
||||
|
||||
if (context.tools && context.tools.length > 0 && options.toolChoice) {
|
||||
config.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: mapToolChoice(options.toolChoice),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
config.toolConfig = undefined;
|
||||
}
|
||||
|
||||
if (options.thinking?.enabled && model.reasoning) {
|
||||
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
|
||||
if (options.thinking.level !== undefined) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
@@ -483,15 +484,18 @@ async function consumeChatStream(
|
||||
}
|
||||
|
||||
function toFunctionTools(tools: Tool[]): Array<FunctionTool & { type: "function" }> {
|
||||
return tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>,
|
||||
strict: false,
|
||||
},
|
||||
}));
|
||||
return tools.map((tool) => {
|
||||
const strict = resolveJsonSchemaStrictSampling(tool, true);
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>,
|
||||
strict: strict ?? false,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function stripSymbolKeys(value: unknown): unknown {
|
||||
|
||||
@@ -47,6 +47,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { uuidv7 } from "../utils/uuid.ts";
|
||||
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
@@ -158,9 +159,16 @@ function getRetryAfterDelayMs(headers: Headers): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function capRetryDelayMs(delayMs: number, options?: StreamOptions): number {
|
||||
class RetryDelayExceededError extends Error {}
|
||||
|
||||
function validateRetryDelayMs(delayMs: number, options?: StreamOptions): number {
|
||||
const maxRetryDelayMs = options?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
|
||||
return maxRetryDelayMs > 0 ? Math.min(delayMs, maxRetryDelayMs) : delayMs;
|
||||
if (maxRetryDelayMs > 0 && delayMs > maxRetryDelayMs) {
|
||||
throw new RetryDelayExceededError(
|
||||
`Server requested ${Math.ceil(delayMs / 1000)}s retry delay (max: ${Math.ceil(maxRetryDelayMs / 1000)}s)`,
|
||||
);
|
||||
}
|
||||
return delayMs;
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
@@ -255,12 +263,17 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
||||
}
|
||||
|
||||
const accountId = extractAccountId(apiKey);
|
||||
let body = buildRequestBody(model, context, options);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
);
|
||||
const cacheSessionId = options?.cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const codexSessionId = clampOpenAIPromptCacheKey(cacheSessionId);
|
||||
let body = buildRequestBody(model, context, options, codexSessionId, grammarToolInputProperties);
|
||||
const nextBody = await options?.onPayload?.(body, model);
|
||||
if (nextBody !== undefined) {
|
||||
body = nextBody as RequestBody;
|
||||
}
|
||||
const codexSessionId = clampOpenAIPromptCacheKey(options?.sessionId);
|
||||
const websocketRequestId = codexSessionId || uuidv7();
|
||||
const sseHeaders = buildSSEHeaders(model.headers, options?.headers, accountId, apiKey, codexSessionId);
|
||||
const websocketHeaders = buildWebSocketHeaders(
|
||||
@@ -275,9 +288,9 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
||||
const websocketConnectTimeoutMs = normalizeTimeoutMs(options?.websocketConnectTimeoutMs);
|
||||
const transport = options?.transport || "auto";
|
||||
let startEmitted = false;
|
||||
const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(options?.sessionId);
|
||||
const websocketDisabledForSession = transport !== "sse" && isWebSocketSseFallbackActive(cacheSessionId);
|
||||
if (websocketDisabledForSession) {
|
||||
recordWebSocketSseFallback(options?.sessionId);
|
||||
recordWebSocketSseFallback(cacheSessionId);
|
||||
}
|
||||
|
||||
if (transport !== "sse" && !websocketDisabledForSession) {
|
||||
@@ -303,6 +316,8 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
||||
},
|
||||
httpTimeoutMs,
|
||||
websocketConnectTimeoutMs,
|
||||
cacheSessionId,
|
||||
grammarToolInputProperties,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -341,11 +356,11 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
||||
requestBytes: new TextEncoder().encode(bodyJson).byteLength,
|
||||
}),
|
||||
);
|
||||
recordWebSocketFailure(options?.sessionId, error);
|
||||
recordWebSocketFailure(cacheSessionId, error);
|
||||
if (websocketStarted) {
|
||||
throw error;
|
||||
}
|
||||
recordWebSocketSseFallback(options?.sessionId);
|
||||
recordWebSocketSseFallback(cacheSessionId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -404,9 +419,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
||||
const delayMs =
|
||||
retryAfterDelayMs === undefined
|
||||
? BASE_DELAY_MS * 2 ** attempt
|
||||
: response.status === 429
|
||||
? capRetryDelayMs(retryAfterDelayMs, options)
|
||||
: retryAfterDelayMs;
|
||||
: validateRetryDelayMs(retryAfterDelayMs, options);
|
||||
|
||||
await sleep(delayMs, options?.signal);
|
||||
continue;
|
||||
@@ -427,7 +440,11 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
||||
}
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
// Network errors are retryable
|
||||
if (attempt < maxRetries && !lastError.message.includes("usage limit")) {
|
||||
if (
|
||||
attempt < maxRetries &&
|
||||
!(lastError instanceof RetryDelayExceededError) &&
|
||||
!lastError.message.includes("usage limit")
|
||||
) {
|
||||
const delayMs = BASE_DELAY_MS * 2 ** attempt;
|
||||
await sleep(delayMs, options?.signal);
|
||||
continue;
|
||||
@@ -448,7 +465,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
||||
startEmitted = true;
|
||||
stream.push({ type: "start", partial: output });
|
||||
}
|
||||
await processStream(response, output, stream, model, options);
|
||||
await processStream(response, output, stream, model, grammarToolInputProperties, options);
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
@@ -458,8 +475,9 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
// partialJson is only a streaming scratch buffer; never persist it.
|
||||
// Streaming scratch buffers are only used during parsing; never persist them.
|
||||
delete (block as { partialJson?: string }).partialJson;
|
||||
delete (block as { customInput?: unknown }).customInput;
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatProviderError(normalizeProviderError(error));
|
||||
@@ -498,12 +516,25 @@ export const streamSimple: StreamFunction<"openai-codex-responses", SimpleStream
|
||||
function buildRequestBody(
|
||||
model: Model<"openai-codex-responses">,
|
||||
context: Context,
|
||||
options?: OpenAICodexResponsesOptions,
|
||||
options: OpenAICodexResponsesOptions | undefined,
|
||||
cacheSessionId: string | undefined,
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
),
|
||||
): RequestBody {
|
||||
const supportsStrictMode = model.compat?.supportsStrictMode ?? true;
|
||||
const supportsOpenAIGrammarTools = model.compat?.supportsOpenAIGrammarTools ?? false;
|
||||
const toolPlacement = splitDeferredTools(context, model.compat?.supportsToolSearch ?? false);
|
||||
const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, {
|
||||
includeSystemPrompt: false,
|
||||
grammarToolInputProperties,
|
||||
deferredTools: toolPlacement.deferred,
|
||||
toolOptions: {
|
||||
strict: null,
|
||||
supportsStrictMode,
|
||||
supportsOpenAIGrammarTools,
|
||||
},
|
||||
});
|
||||
|
||||
const body: RequestBody = {
|
||||
@@ -514,7 +545,7 @@ function buildRequestBody(
|
||||
input: messages,
|
||||
text: { verbosity: options?.textVerbosity || "low" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
|
||||
prompt_cache_key: cacheSessionId,
|
||||
tool_choice: options?.toolChoice ?? "auto",
|
||||
parallel_tool_calls: true,
|
||||
};
|
||||
@@ -528,7 +559,11 @@ function buildRequestBody(
|
||||
}
|
||||
|
||||
if (toolPlacement.immediate.length > 0) {
|
||||
body.tools = convertResponsesTools(toolPlacement.immediate, { strict: null });
|
||||
body.tools = convertResponsesTools(toolPlacement.immediate, {
|
||||
strict: null,
|
||||
supportsStrictMode,
|
||||
supportsOpenAIGrammarTools,
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.reasoningEffort !== undefined) {
|
||||
@@ -610,10 +645,12 @@ async function processStream(
|
||||
output: AssistantMessage,
|
||||
stream: AssistantMessageEventStream,
|
||||
model: Model<"openai-codex-responses">,
|
||||
grammarToolInputProperties: ReadonlyMap<string, string>,
|
||||
options?: OpenAICodexResponsesOptions,
|
||||
): Promise<void> {
|
||||
await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, {
|
||||
serviceTier: options?.serviceTier,
|
||||
grammarToolInputProperties,
|
||||
resolveServiceTier: resolveCodexServiceTier,
|
||||
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
|
||||
});
|
||||
@@ -1399,12 +1436,14 @@ async function processWebSocketStream(
|
||||
onStart: () => void,
|
||||
idleTimeoutMs: number | undefined,
|
||||
websocketConnectTimeoutMs: number | undefined,
|
||||
cacheSessionId: string | undefined,
|
||||
grammarToolInputProperties: ReadonlyMap<string, string>,
|
||||
options?: OpenAICodexResponsesOptions,
|
||||
): Promise<void> {
|
||||
const { socket, entry, reused, release } = await acquireWebSocket(
|
||||
url,
|
||||
headers,
|
||||
options?.sessionId,
|
||||
cacheSessionId,
|
||||
options?.signal,
|
||||
websocketConnectTimeoutMs,
|
||||
options?.env,
|
||||
@@ -1415,7 +1454,7 @@ async function processWebSocketStream(
|
||||
// WebSocket continuation still works via connection-scoped previous_response_id state.
|
||||
const fullBody = body;
|
||||
const requestBody = useCachedContext && entry ? buildCachedWebSocketRequestBody(entry, fullBody) : fullBody;
|
||||
const stats = options?.sessionId ? getOrCreateWebSocketDebugStats(options.sessionId) : undefined;
|
||||
const stats = cacheSessionId ? getOrCreateWebSocketDebugStats(cacheSessionId) : undefined;
|
||||
if (stats) {
|
||||
stats.requests++;
|
||||
if (reused) stats.connectionsReused++;
|
||||
@@ -1445,6 +1484,7 @@ async function processWebSocketStream(
|
||||
model,
|
||||
{
|
||||
serviceTier: options?.serviceTier,
|
||||
grammarToolInputProperties,
|
||||
resolveServiceTier: resolveCodexServiceTier,
|
||||
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
|
||||
},
|
||||
@@ -1454,7 +1494,8 @@ async function processWebSocketStream(
|
||||
} else if (useCachedContext && entry && output.responseId) {
|
||||
const responseItems = convertResponsesMessages(model, { messages: [output] }, CODEX_TOOL_CALL_PROVIDERS, {
|
||||
includeSystemPrompt: false,
|
||||
}).filter((item) => item.type !== "function_call_output");
|
||||
grammarToolInputProperties,
|
||||
}).filter((item) => item.type !== "function_call_output" && item.type !== "custom_tool_call_output");
|
||||
entry.continuation = {
|
||||
lastRequestBody: fullBody,
|
||||
lastResponseId: output.responseId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ChatCompletionContentPartText,
|
||||
ChatCompletionDeveloperMessageParam,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionToolMessageParam,
|
||||
} from "openai/resources/chat/completions.js";
|
||||
@@ -38,7 +39,16 @@ import { shortHash } from "../utils/hash.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import {
|
||||
appendGrammarToolInputJsonDelta,
|
||||
createGrammarToolInputProperties,
|
||||
type GrammarToolInputJsonBuffer,
|
||||
getGrammarToolInput,
|
||||
resolveGrammarConstrainedSampling,
|
||||
resolveJsonSchemaStrictSampling,
|
||||
} from "./constrained-sampling.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
@@ -129,10 +139,14 @@ function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedR
|
||||
}
|
||||
|
||||
export interface OpenAICompletionsOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } };
|
||||
toolChoice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption;
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
}
|
||||
|
||||
export interface ConvertCompletionsMessagesOptions {
|
||||
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
interface OpenAICompatCacheControl {
|
||||
type: "ephemeral";
|
||||
ttl?: string;
|
||||
@@ -208,10 +222,14 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
try {
|
||||
const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
|
||||
const compat = getCompat(model);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
);
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat);
|
||||
let params = buildParams(model, context, options, compat, cacheRetention);
|
||||
let params = buildParams(model, context, options, compat, cacheRetention, grammarToolInputProperties);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;
|
||||
@@ -219,20 +237,35 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
||||
maxRetries: options?.maxRetries ?? 0,
|
||||
maxRetries: 0,
|
||||
};
|
||||
const { data: openaiStream, response } = await client.chat.completions
|
||||
.create(params, requestOptions)
|
||||
.withResponse();
|
||||
const { data: openaiStream, response } = await retryProviderRequest(
|
||||
() => client.chat.completions.create(params, requestOptions).withResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
interface StreamingToolCallBlock extends ToolCall {
|
||||
partialArgs?: string;
|
||||
customInput?: {
|
||||
property: string;
|
||||
jsonBuffer: GrammarToolInputJsonBuffer;
|
||||
};
|
||||
streamIndex?: number;
|
||||
}
|
||||
type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock;
|
||||
type StreamingToolCallDelta = NonNullable<ChatCompletionChunk.Choice.Delta["tool_calls"]>[number];
|
||||
type StreamingToolCallDelta = {
|
||||
index?: number;
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: { name?: string; arguments?: string };
|
||||
custom?: { name?: string; input?: string };
|
||||
};
|
||||
|
||||
let textBlock: TextContent | null = null;
|
||||
let thinkingBlock: ThinkingContent | null = null;
|
||||
@@ -242,6 +275,28 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
const pendingReasoningDetailsByToolCallId = new Map<string, string>();
|
||||
const blocks = output.content as StreamingBlock[];
|
||||
const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block);
|
||||
const getCustomToolCallInput = (block: StreamingToolCallBlock): string => {
|
||||
const property = block.customInput?.property;
|
||||
if (property === undefined) return "";
|
||||
const value = block.arguments[property];
|
||||
return typeof value === "string" ? value : "";
|
||||
};
|
||||
const appendCustomToolCallInput = (
|
||||
block: StreamingToolCallBlock,
|
||||
nextInput: string,
|
||||
close: boolean,
|
||||
): string | undefined => {
|
||||
const customInput = block.customInput;
|
||||
if (!customInput) return undefined;
|
||||
const delta = appendGrammarToolInputJsonDelta(
|
||||
customInput.jsonBuffer,
|
||||
customInput.property,
|
||||
nextInput,
|
||||
close,
|
||||
);
|
||||
block.arguments = { [customInput.property]: nextInput };
|
||||
return delta;
|
||||
};
|
||||
const finishBlock = (block: StreamingBlock) => {
|
||||
const contentIndex = getContentIndex(block);
|
||||
if (contentIndex === -1) {
|
||||
@@ -262,10 +317,23 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
partial: output,
|
||||
});
|
||||
} else if (block.type === "toolCall") {
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
if (block.customInput) {
|
||||
const delta = appendCustomToolCallInput(block, getCustomToolCallInput(block), true);
|
||||
if (delta !== undefined) {
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex,
|
||||
delta,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
}
|
||||
// Finalize in-place and strip the scratch buffers so replay only
|
||||
// carries parsed arguments.
|
||||
delete block.partialArgs;
|
||||
delete block.customInput;
|
||||
delete block.streamIndex;
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
@@ -307,17 +375,27 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
};
|
||||
const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => {
|
||||
const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined;
|
||||
const name = toolCall.function?.name ?? toolCall.custom?.name ?? "";
|
||||
let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined;
|
||||
if (!block && toolCall.id) {
|
||||
block = toolCallBlocksById.get(toolCall.id);
|
||||
}
|
||||
if (!block) {
|
||||
// Note: the "input" fallback here should/must not be taken. in case the LLM makes up
|
||||
// a tool we don't knwo about, we at least have a place to stash our stuff.
|
||||
const customInputProperty = toolCall.custom
|
||||
? (grammarToolInputProperties.get(name) ?? "input")
|
||||
: undefined;
|
||||
const hasCustomInput = customInputProperty !== undefined;
|
||||
block = {
|
||||
type: "toolCall",
|
||||
id: toolCall.id || "",
|
||||
name: toolCall.function?.name || "",
|
||||
arguments: {},
|
||||
partialArgs: "",
|
||||
name,
|
||||
arguments: hasCustomInput ? { [customInputProperty]: "" } : {},
|
||||
partialArgs: hasCustomInput ? undefined : "",
|
||||
customInput: hasCustomInput
|
||||
? { property: customInputProperty, jsonBuffer: { input: "", started: false, closed: false } }
|
||||
: undefined,
|
||||
streamIndex,
|
||||
};
|
||||
if (streamIndex !== undefined) {
|
||||
@@ -340,6 +418,18 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
if (toolCall.id) {
|
||||
toolCallBlocksById.set(toolCall.id, block);
|
||||
}
|
||||
if (!block.name && name) {
|
||||
block.name = name;
|
||||
}
|
||||
if (toolCall.custom && !block.customInput) {
|
||||
const customInputProperty = grammarToolInputProperties.get(block.name) ?? "input";
|
||||
block.arguments = { [customInputProperty]: "" };
|
||||
block.customInput = {
|
||||
property: customInputProperty,
|
||||
jsonBuffer: { input: "", started: false, closed: false },
|
||||
};
|
||||
delete block.partialArgs;
|
||||
}
|
||||
applyPendingReasoningDetail(block);
|
||||
return block;
|
||||
};
|
||||
@@ -425,14 +515,15 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
}
|
||||
|
||||
if (choice?.delta?.tool_calls) {
|
||||
for (const toolCall of choice.delta.tool_calls) {
|
||||
for (const toolCall of choice.delta.tool_calls as StreamingToolCallDelta[]) {
|
||||
const block = ensureToolCallBlock(toolCall);
|
||||
if (!block.id && toolCall.id) {
|
||||
block.id = toolCall.id;
|
||||
toolCallBlocksById.set(toolCall.id, block);
|
||||
}
|
||||
if (!block.name && toolCall.function?.name) {
|
||||
block.name = toolCall.function.name;
|
||||
const name = toolCall.function?.name ?? toolCall.custom?.name;
|
||||
if (!block.name && name) {
|
||||
block.name = name;
|
||||
}
|
||||
|
||||
let delta = "";
|
||||
@@ -440,6 +531,9 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
delta = toolCall.function.arguments;
|
||||
block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
|
||||
block.arguments = parseStreamingJson(block.partialArgs);
|
||||
} else if (toolCall.custom?.input) {
|
||||
const nextInput = getCustomToolCallInput(block) + toolCall.custom.input;
|
||||
delta = appendCustomToolCallInput(block, nextInput, false) ?? "";
|
||||
}
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
@@ -491,6 +585,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
delete (block as { index?: number }).index;
|
||||
// Streaming scratch buffers are only used during parsing; never persist them.
|
||||
delete (block as { partialArgs?: string }).partialArgs;
|
||||
delete (block as { customInput?: unknown }).customInput;
|
||||
delete (block as { streamIndex?: number }).streamIndex;
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
@@ -579,8 +674,12 @@ function buildParams(
|
||||
options?: OpenAICompletionsOptions,
|
||||
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
|
||||
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env),
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
),
|
||||
) {
|
||||
const messages = convertMessages(model, context, compat);
|
||||
const messages = convertMessages(model, context, compat, { grammarToolInputProperties });
|
||||
const cacheControl = getCompatCacheControl(compat, cacheRetention);
|
||||
|
||||
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
@@ -889,6 +988,7 @@ export function convertMessages(
|
||||
model: Model<"openai-completions">,
|
||||
context: Context,
|
||||
compat: ResolvedOpenAICompletionsCompat,
|
||||
options?: ConvertCompletionsMessagesOptions,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const params: ChatCompletionMessageParam[] = [];
|
||||
|
||||
@@ -1026,14 +1126,27 @@ export function convertMessages(
|
||||
|
||||
const toolCalls = msg.content.filter(isToolCallBlock);
|
||||
if (toolCalls.length > 0) {
|
||||
assistantMsg.tool_calls = toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.arguments),
|
||||
},
|
||||
}));
|
||||
assistantMsg.tool_calls = toolCalls.map((tc): ChatCompletionMessageToolCall => {
|
||||
const customInputProperty = options?.grammarToolInputProperties?.get(tc.name);
|
||||
if (customInputProperty !== undefined) {
|
||||
return {
|
||||
id: tc.id,
|
||||
type: "custom",
|
||||
custom: {
|
||||
name: tc.name,
|
||||
input: sanitizeSurrogates(getGrammarToolInput(tc.name, tc.arguments, customInputProperty)),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.arguments),
|
||||
},
|
||||
};
|
||||
});
|
||||
const reasoningDetails = toolCalls
|
||||
.filter((tc) => tc.thoughtSignature)
|
||||
.map((tc) => {
|
||||
@@ -1166,16 +1279,37 @@ function convertTools(
|
||||
tools: Tool[],
|
||||
compat: ResolvedOpenAICompletionsCompat,
|
||||
): OpenAI.Chat.Completions.ChatCompletionTool[] {
|
||||
return tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as any, // TypeBox already generates JSON Schema
|
||||
// Only include strict if provider supports it. Some reject unknown fields.
|
||||
...(compat.supportsStrictMode !== false && { strict: false }),
|
||||
},
|
||||
}));
|
||||
return tools.map((tool) => {
|
||||
const grammar = resolveGrammarConstrainedSampling(tool, compat.supportsOpenAIGrammarTools);
|
||||
if (grammar) {
|
||||
return {
|
||||
type: "custom",
|
||||
custom: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
format: {
|
||||
type: "grammar",
|
||||
grammar: {
|
||||
syntax: grammar.format,
|
||||
definition: grammar.definition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const strict = resolveJsonSchemaStrictSampling(tool, compat.supportsStrictMode !== false);
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as Record<string, unknown>, // TypeBox already generates JSON Schema
|
||||
// Only include strict if provider supports it. Some reject unknown fields.
|
||||
...(compat.supportsStrictMode !== false && { strict: strict ?? false }),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseChunkUsage(
|
||||
@@ -1318,6 +1452,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
|
||||
chatTemplateKwargs: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
cacheControlFormat,
|
||||
sendSessionAffinityHeaders: false,
|
||||
deferredToolsMode: undefined,
|
||||
@@ -1359,6 +1494,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
|
||||
chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs,
|
||||
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
|
||||
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
|
||||
supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools,
|
||||
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,
|
||||
sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
|
||||
deferredToolsMode: model.compat.deferredToolsMode ?? detected.deferredToolsMode,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type OpenAI from "openai";
|
||||
import type {
|
||||
Tool as OpenAITool,
|
||||
ResponseCreateParamsStreaming,
|
||||
ResponseFunctionCallOutputItemList,
|
||||
ResponseInput,
|
||||
ResponseInputContent,
|
||||
ResponseInputImage,
|
||||
@@ -33,6 +32,13 @@ import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import {
|
||||
appendGrammarToolInputJsonDelta,
|
||||
type GrammarToolInputJsonBuffer,
|
||||
getGrammarToolInput,
|
||||
resolveGrammarConstrainedSampling,
|
||||
resolveJsonSchemaStrictSampling,
|
||||
} from "./constrained-sampling.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
// =============================================================================
|
||||
@@ -65,8 +71,40 @@ function parseTextSignature(
|
||||
return { id: signature };
|
||||
}
|
||||
|
||||
type ToolResultOutputContent = Array<ResponseInputText | ResponseInputImage>;
|
||||
|
||||
function convertToolResultOutput<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
content: readonly (TextContent | ImageContent)[],
|
||||
): string | ToolResultOutputContent {
|
||||
const textResult = content
|
||||
.filter((c): c is TextContent => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
const images = content.filter((c): c is ImageContent => c.type === "image");
|
||||
const hasText = textResult.length > 0;
|
||||
|
||||
if (images.length === 0 || !model.input.includes("image")) {
|
||||
return sanitizeSurrogates(hasText ? textResult : images.length > 0 ? "(see attached image)" : "(no tool output)");
|
||||
}
|
||||
|
||||
const output: ToolResultOutputContent = [];
|
||||
if (hasText) {
|
||||
output.push({ type: "input_text", text: sanitizeSurrogates(textResult) });
|
||||
}
|
||||
for (const image of images) {
|
||||
output.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${image.mimeType};base64,${image.data}`,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface OpenAIResponsesStreamOptions {
|
||||
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
||||
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
||||
resolveServiceTier?: (
|
||||
responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
@@ -79,16 +117,18 @@ export interface OpenAIResponsesStreamOptions {
|
||||
|
||||
export interface ConvertResponsesMessagesOptions {
|
||||
includeSystemPrompt?: boolean;
|
||||
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
||||
deferredTools?: ReadonlyMap<string, Tool>;
|
||||
toolOptions?: ConvertResponsesToolsOptions;
|
||||
}
|
||||
|
||||
export interface ConvertResponsesToolsOptions {
|
||||
strict?: boolean | null;
|
||||
supportsStrictMode?: boolean;
|
||||
supportsOpenAIGrammarTools?: boolean;
|
||||
deferLoading?: boolean;
|
||||
}
|
||||
|
||||
type OpenAIFunctionTool = Extract<OpenAITool, { type: "function" }>;
|
||||
|
||||
// =============================================================================
|
||||
// Message conversion
|
||||
// =============================================================================
|
||||
@@ -206,67 +246,62 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
} else if (block.type === "toolCall") {
|
||||
const toolCall = block as ToolCall;
|
||||
const [callId, itemIdRaw] = toolCall.id.split("|");
|
||||
const customInputProperty = options?.grammarToolInputProperties?.get(toolCall.name);
|
||||
let itemId: string | undefined = itemIdRaw;
|
||||
|
||||
// For different-model messages, set id to undefined to avoid pairing validation.
|
||||
// OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
|
||||
// By omitting the id, we avoid triggering that validation (like cross-provider does).
|
||||
if (isDifferentModel && itemId?.startsWith("fc_")) {
|
||||
// When replaying custom-tool calls as a function_call, also drop non-fc_* ids such as
|
||||
// ctc_* custom-tool ids because function_call item ids must be fc_*.
|
||||
if (
|
||||
(isDifferentModel && itemId?.startsWith("fc_")) ||
|
||||
(customInputProperty === undefined && !itemId?.startsWith("fc_"))
|
||||
) {
|
||||
itemId = undefined;
|
||||
}
|
||||
|
||||
output.push({
|
||||
type: "function_call",
|
||||
id: itemId,
|
||||
call_id: callId,
|
||||
name: toolCall.name,
|
||||
arguments: JSON.stringify(toolCall.arguments),
|
||||
});
|
||||
if (customInputProperty !== undefined) {
|
||||
output.push({
|
||||
type: "custom_tool_call",
|
||||
id: itemId,
|
||||
call_id: callId,
|
||||
name: toolCall.name,
|
||||
input: sanitizeSurrogates(
|
||||
getGrammarToolInput(toolCall.name, toolCall.arguments, customInputProperty),
|
||||
),
|
||||
} satisfies ResponseOutputItem);
|
||||
} else {
|
||||
output.push({
|
||||
type: "function_call",
|
||||
id: itemId,
|
||||
call_id: callId,
|
||||
name: toolCall.name,
|
||||
arguments: JSON.stringify(toolCall.arguments),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (output.length === 0) continue;
|
||||
messages.push(...output);
|
||||
} else if (msg.role === "toolResult") {
|
||||
const textResult = msg.content
|
||||
.filter((c): c is TextContent => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
const hasImages = msg.content.some((c): c is ImageContent => c.type === "image");
|
||||
const hasText = textResult.length > 0;
|
||||
const [callId] = msg.toolCallId.split("|");
|
||||
const output = convertToolResultOutput(model, msg.content);
|
||||
|
||||
let output: string | ResponseFunctionCallOutputItemList;
|
||||
if (hasImages && model.input.includes("image")) {
|
||||
const contentParts: ResponseFunctionCallOutputItemList = [];
|
||||
|
||||
if (hasText) {
|
||||
contentParts.push({
|
||||
type: "input_text",
|
||||
text: sanitizeSurrogates(textResult),
|
||||
});
|
||||
}
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "image") {
|
||||
contentParts.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${block.mimeType};base64,${block.data}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
output = contentParts;
|
||||
if (options?.grammarToolInputProperties?.has(msg.toolName)) {
|
||||
messages.push({
|
||||
type: "custom_tool_call_output",
|
||||
call_id: callId,
|
||||
output,
|
||||
});
|
||||
} else {
|
||||
output = sanitizeSurrogates(hasText ? textResult : hasImages ? "(see attached image)" : "(no tool output)");
|
||||
messages.push({
|
||||
type: "function_call_output",
|
||||
call_id: callId,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: "function_call_output",
|
||||
call_id: callId,
|
||||
output,
|
||||
});
|
||||
|
||||
const deferredTools: Tool[] = [];
|
||||
for (const name of msg.addedToolNames ?? []) {
|
||||
const tool = options?.deferredTools?.get(name);
|
||||
@@ -289,7 +324,10 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
call_id: searchCallId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
tools: convertResponsesTools(deferredTools, { deferLoading: true }),
|
||||
tools: convertResponsesTools(deferredTools, {
|
||||
...options?.toolOptions,
|
||||
deferLoading: true,
|
||||
}),
|
||||
} satisfies ResponseToolSearchOutputItemParam);
|
||||
}
|
||||
}
|
||||
@@ -304,30 +342,77 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
// =============================================================================
|
||||
|
||||
export function convertResponsesTools(tools: readonly Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] {
|
||||
const strict = options?.strict === undefined ? false : options.strict;
|
||||
return tools.map(
|
||||
(tool): OpenAIFunctionTool => ({
|
||||
const defaultStrict = options?.strict === undefined ? false : options.strict;
|
||||
const supportsStrictMode = options?.supportsStrictMode ?? true;
|
||||
const supportsOpenAIGrammarTools = options?.supportsOpenAIGrammarTools ?? false;
|
||||
|
||||
return tools.map((tool) => {
|
||||
const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools);
|
||||
if (grammar) {
|
||||
return {
|
||||
type: "custom",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
format: {
|
||||
type: "grammar",
|
||||
syntax: grammar.format,
|
||||
definition: grammar.definition,
|
||||
},
|
||||
...(options?.deferLoading ? { defer_loading: true } : {}),
|
||||
} satisfies OpenAITool;
|
||||
}
|
||||
|
||||
const constrainedStrict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
|
||||
const functionTool: Omit<Extract<OpenAITool, { type: "function" }>, "strict"> & {
|
||||
strict?: Extract<OpenAITool, { type: "function" }>["strict"];
|
||||
} = {
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as Record<string, unknown>, // TypeBox already generates JSON Schema
|
||||
strict,
|
||||
...(options?.deferLoading ? { defer_loading: true } : {}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (supportsStrictMode) {
|
||||
functionTool.strict = constrainedStrict ?? defaultStrict;
|
||||
}
|
||||
return functionTool as OpenAITool;
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stream processing
|
||||
// =============================================================================
|
||||
|
||||
type StreamingToolCall = ToolCall & { partialJson: string };
|
||||
type StreamingToolCall = ToolCall & {
|
||||
partialJson?: string;
|
||||
customInput?: {
|
||||
property: string;
|
||||
jsonBuffer: GrammarToolInputJsonBuffer;
|
||||
};
|
||||
};
|
||||
|
||||
function getCustomToolCallInput(block: StreamingToolCall): string {
|
||||
const property = block.customInput?.property;
|
||||
if (property === undefined) return "";
|
||||
const value = block.arguments[property];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function appendCustomToolCallInput(block: StreamingToolCall, nextInput: string, close: boolean): string | undefined {
|
||||
const customInput = block.customInput;
|
||||
if (!customInput) return undefined;
|
||||
const delta = appendGrammarToolInputJsonDelta(customInput.jsonBuffer, customInput.property, nextInput, close);
|
||||
block.arguments = { [customInput.property]: nextInput };
|
||||
return delta;
|
||||
}
|
||||
|
||||
type ResponsesOutputSlot =
|
||||
| { type: "thinking"; block: ThinkingContent; contentIndex: number }
|
||||
| { type: "text"; block: TextContent; contentIndex: number }
|
||||
| { type: "toolCall"; block: StreamingToolCall; contentIndex: number };
|
||||
|
||||
type ToolCallOutputSlot = Extract<ResponsesOutputSlot, { type: "toolCall" }>;
|
||||
|
||||
export async function processResponsesStream<TApi extends Api>(
|
||||
openaiStream: AsyncIterable<ResponseStreamEvent>,
|
||||
output: AssistantMessage,
|
||||
@@ -345,6 +430,15 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
const slot = outputSlots.get(outputIndex);
|
||||
return slot?.type === type ? (slot as Extract<ResponsesOutputSlot, { type: TType }>) : undefined;
|
||||
};
|
||||
const pushToolCallDelta = (slot: ToolCallOutputSlot, delta: string | undefined): void => {
|
||||
if (delta === undefined) return;
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta,
|
||||
partial: output,
|
||||
});
|
||||
};
|
||||
const createSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
|
||||
if (item.type === "reasoning") {
|
||||
const block: ThinkingContent = { type: "thinking", thinking: "" };
|
||||
@@ -384,6 +478,29 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
|
||||
return slot;
|
||||
}
|
||||
if (item.type === "custom_tool_call") {
|
||||
const inputProperty = options?.grammarToolInputProperties?.get(item.name) ?? "input";
|
||||
const input = item.input || "";
|
||||
const block: StreamingToolCall = {
|
||||
type: "toolCall",
|
||||
id: `${item.call_id}|${item.id}`,
|
||||
name: item.name,
|
||||
arguments: { [inputProperty]: input },
|
||||
customInput: {
|
||||
property: inputProperty,
|
||||
jsonBuffer: { input: "", started: false, closed: false },
|
||||
},
|
||||
};
|
||||
output.content.push(block);
|
||||
const slot = {
|
||||
type: "toolCall",
|
||||
block,
|
||||
contentIndex: output.content.length - 1,
|
||||
} satisfies ResponsesOutputSlot;
|
||||
outputSlots.set(outputIndex, slot);
|
||||
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
|
||||
return slot;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const getOrCreateSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
|
||||
@@ -503,33 +620,32 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
});
|
||||
} else if (event.type === "response.function_call_arguments.delta") {
|
||||
const slot = getSlot(event.output_index, "toolCall");
|
||||
if (!slot) continue;
|
||||
if (!slot || slot.block.partialJson === undefined) continue;
|
||||
slot.block.partialJson += event.delta;
|
||||
slot.block.arguments = parseStreamingJson(slot.block.partialJson);
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta: event.delta,
|
||||
partial: output,
|
||||
});
|
||||
pushToolCallDelta(slot, event.delta);
|
||||
} else if (event.type === "response.function_call_arguments.done") {
|
||||
const slot = getSlot(event.output_index, "toolCall");
|
||||
if (!slot) continue;
|
||||
if (!slot || slot.block.partialJson === undefined) continue;
|
||||
const previousPartialJson = slot.block.partialJson;
|
||||
slot.block.partialJson = event.arguments;
|
||||
slot.block.arguments = parseStreamingJson(slot.block.partialJson);
|
||||
|
||||
if (event.arguments.startsWith(previousPartialJson)) {
|
||||
const delta = event.arguments.slice(previousPartialJson.length);
|
||||
if (delta.length > 0) {
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
if (delta.length > 0) pushToolCallDelta(slot, delta);
|
||||
}
|
||||
} else if (event.type === "response.custom_tool_call_input.delta") {
|
||||
const slot = getSlot(event.output_index, "toolCall");
|
||||
if (!slot || !slot.block.customInput) continue;
|
||||
pushToolCallDelta(
|
||||
slot,
|
||||
appendCustomToolCallInput(slot.block, getCustomToolCallInput(slot.block) + event.delta, false),
|
||||
);
|
||||
} else if (event.type === "response.custom_tool_call_input.done") {
|
||||
const slot = getSlot(event.output_index, "toolCall");
|
||||
if (!slot || !slot.block.customInput) continue;
|
||||
pushToolCallDelta(slot, appendCustomToolCallInput(slot.block, event.input, true));
|
||||
} else if (event.type === "response.output_item.done") {
|
||||
const item = event.item;
|
||||
const slot = getOrCreateSlot(event.output_index, item);
|
||||
@@ -557,11 +673,28 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
partial: output,
|
||||
});
|
||||
outputSlots.delete(event.output_index);
|
||||
} else if (item.type === "function_call" && slot?.type === "toolCall") {
|
||||
} else if (
|
||||
item.type === "function_call" &&
|
||||
slot?.type === "toolCall" &&
|
||||
slot.block.partialJson !== undefined
|
||||
) {
|
||||
slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}");
|
||||
// Finalize in-place and strip the scratch buffer so replay only
|
||||
// carries parsed arguments.
|
||||
delete (slot.block as { partialJson?: string }).partialJson;
|
||||
delete slot.block.partialJson;
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: slot.contentIndex,
|
||||
toolCall: slot.block,
|
||||
partial: output,
|
||||
});
|
||||
outputSlots.delete(event.output_index);
|
||||
} else if (item.type === "custom_tool_call" && slot?.type === "toolCall" && slot.block.customInput) {
|
||||
pushToolCallDelta(
|
||||
slot,
|
||||
appendCustomToolCallInput(slot.block, item.input ?? getCustomToolCallInput(slot.block), true),
|
||||
);
|
||||
delete slot.block.customInput;
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: slot.contentIndex,
|
||||
|
||||
@@ -20,6 +20,8 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
|
||||
@@ -67,7 +69,10 @@ function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCo
|
||||
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
|
||||
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
|
||||
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||
supportsStrictMode: model.compat?.supportsStrictMode ?? false,
|
||||
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
supportsToolSearch: model.compat?.supportsToolSearch ?? false,
|
||||
supportsExplicitPromptCacheMode: model.compat?.supportsExplicitPromptCacheMode ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,8 +130,13 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
|
||||
const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const compat = getCompat(model);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
);
|
||||
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId);
|
||||
let params = buildParams(model, context, options);
|
||||
let params = buildParams(model, context, options, compat, grammarToolInputProperties);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as ResponseCreateParamsStreaming;
|
||||
@@ -134,14 +144,22 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
||||
maxRetries: options?.maxRetries ?? 0,
|
||||
maxRetries: 0,
|
||||
};
|
||||
const { data: openaiStream, response } = await client.responses.create(params, requestOptions).withResponse();
|
||||
const { data: openaiStream, response } = await retryProviderRequest(
|
||||
() => client.responses.create(params, requestOptions).withResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
await processResponsesStream(openaiStream, output, stream, model, {
|
||||
serviceTier: options?.serviceTier,
|
||||
grammarToolInputProperties,
|
||||
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
|
||||
});
|
||||
|
||||
@@ -158,8 +176,9 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
delete (block as { index?: number }).index;
|
||||
// partialJson is only a streaming scratch buffer; never persist it.
|
||||
// Streaming scratch buffers are only used during parsing; never persist them.
|
||||
delete (block as { partialJson?: string }).partialJson;
|
||||
delete (block as { customInput?: unknown }).customInput;
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatOpenAIResponsesError(error);
|
||||
@@ -230,20 +249,35 @@ function createClient(
|
||||
});
|
||||
}
|
||||
|
||||
function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) {
|
||||
const compat = getCompat(model);
|
||||
function buildParams(
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
options: OpenAIResponsesOptions | undefined,
|
||||
compat: Required<OpenAIResponsesCompat> = getCompat(model),
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
),
|
||||
) {
|
||||
const toolPlacement = splitDeferredTools(context, compat.supportsToolSearch);
|
||||
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, {
|
||||
grammarToolInputProperties,
|
||||
deferredTools: toolPlacement.deferred,
|
||||
toolOptions: {
|
||||
supportsStrictMode: compat.supportsStrictMode,
|
||||
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
|
||||
},
|
||||
});
|
||||
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const params: ResponseCreateParamsStreaming = {
|
||||
const disableImplicitPromptCache = cacheRetention === "none" && compat.supportsExplicitPromptCacheMode;
|
||||
const params: ResponseCreateParamsStreaming & { prompt_cache_options?: { mode: "explicit" } } = {
|
||||
model: model.id,
|
||||
input: messages,
|
||||
stream: true,
|
||||
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
|
||||
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
|
||||
prompt_cache_options: disableImplicitPromptCache ? { mode: "explicit" } : undefined,
|
||||
store: false,
|
||||
};
|
||||
|
||||
@@ -260,7 +294,10 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
|
||||
}
|
||||
|
||||
if (toolPlacement.immediate.length > 0) {
|
||||
params.tools = convertResponsesTools(toolPlacement.immediate);
|
||||
params.tools = convertResponsesTools(toolPlacement.immediate, {
|
||||
supportsStrictMode: compat.supportsStrictMode,
|
||||
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.toolChoice !== undefined) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
||||
interface OpenRouterGeneratedImage {
|
||||
@@ -64,11 +65,19 @@ export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions>
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
||||
maxRetries: options?.maxRetries ?? 0,
|
||||
maxRetries: 0,
|
||||
};
|
||||
const { data: response, response: rawResponse } = await client.chat.completions
|
||||
.create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions)
|
||||
.withResponse();
|
||||
const { data: response, response: rawResponse } = await retryProviderRequest(
|
||||
() =>
|
||||
client.chat.completions
|
||||
.create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions)
|
||||
.withResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
await options?.onResponse?.({ status: rawResponse.status, headers: headersToRecord(rawResponse.headers) }, model);
|
||||
|
||||
const imageResponse = response as OpenRouterImageGenerationResponse;
|
||||
|
||||
@@ -15,6 +15,7 @@ type OAuthFlowLoaders = {
|
||||
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
xai: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
|
||||
@@ -42,6 +43,11 @@ export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
|
||||
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
|
||||
};
|
||||
|
||||
export const loadOpenRouterOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.openrouter();
|
||||
return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth;
|
||||
};
|
||||
|
||||
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.kimiCoding();
|
||||
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* OpenRouter OAuth PKCE flow.
|
||||
*
|
||||
* OpenRouter exchanges an authorization code for a permanent, user-controlled
|
||||
* API key rather than an expiring access/refresh token pair. The callback is
|
||||
* handled by a one-shot loopback server on an ephemeral port.
|
||||
*
|
||||
* NOTE: This module uses Node.js http.createServer for the OAuth callback server.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type ServerResponse } from "node:http";
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
|
||||
const AUTHORIZE_URL = "https://openrouter.ai/auth";
|
||||
const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
|
||||
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function getCallbackHost(): string {
|
||||
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type OpenRouterCallbackServer = {
|
||||
callbackUrl: string;
|
||||
credential: Promise<OAuthCredential>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
function sendHtml(response: ServerResponse, status: number, html: string): void {
|
||||
response.statusCode = status;
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.setHeader("cache-control", "no-store");
|
||||
response.end(html);
|
||||
}
|
||||
|
||||
function errorDetail(body: JsonObject): string | undefined {
|
||||
if (typeof body.error_description === "string") return body.error_description;
|
||||
if (typeof body.message === "string") return body.message;
|
||||
if (typeof body.error === "string") return body.error;
|
||||
if (body.error && typeof body.error === "object" && !Array.isArray(body.error)) {
|
||||
const message = (body.error as JsonObject).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
if (signal?.aborted) throw new Error("Login cancelled");
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal?.reason);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new Error("OpenRouter OAuth token exchange timed out")),
|
||||
TOKEN_EXCHANGE_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
let body: JsonObject = {};
|
||||
try {
|
||||
response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
const parsed = (await response.json()) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed as JsonObject;
|
||||
} catch {
|
||||
if (response.ok) throw new Error("OpenRouter OAuth returned invalid JSON");
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new Error("Login cancelled");
|
||||
if (controller.signal.aborted) throw new Error("OpenRouter OAuth token exchange timed out");
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = errorDetail(body);
|
||||
throw new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
if (typeof body.key !== "string" || body.key.length === 0) {
|
||||
throw new Error('OpenRouter OAuth response carries no "key"');
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
access: body.key,
|
||||
refresh: "",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
}
|
||||
|
||||
async function startCallbackServer(
|
||||
callbackPath: string,
|
||||
verifier: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OpenRouterCallbackServer> {
|
||||
if (signal?.aborted) throw new Error("Login cancelled");
|
||||
const callbackHost = getCallbackHost();
|
||||
let resolveCredential: (credential: OAuthCredential) => void = () => {};
|
||||
let rejectCredential: (error: Error) => void = () => {};
|
||||
const credential = new Promise<OAuthCredential>((resolve, reject) => {
|
||||
resolveCredential = resolve;
|
||||
rejectCredential = reject;
|
||||
});
|
||||
|
||||
let server: Server;
|
||||
let claimed = false;
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
|
||||
const finish = (result: { credential: OAuthCredential } | { error: Error }): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||
server.close();
|
||||
if ("credential" in result) resolveCredential(result.credential);
|
||||
else rejectCredential(result.error);
|
||||
};
|
||||
|
||||
server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
const requestUrl = new URL(request.url ?? "/", `http://${callbackHost}`);
|
||||
if (request.method !== "GET" || requestUrl.pathname !== callbackPath) {
|
||||
sendHtml(response, 404, oauthErrorHtml("OAuth callback route not found."));
|
||||
return;
|
||||
}
|
||||
if (claimed || settled) {
|
||||
sendHtml(response, 409, oauthErrorHtml("This OAuth callback has already been used."));
|
||||
return;
|
||||
}
|
||||
|
||||
const oauthError = requestUrl.searchParams.get("error");
|
||||
if (oauthError) {
|
||||
const description = requestUrl.searchParams.get("error_description") ?? oauthError;
|
||||
sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description));
|
||||
finish({ error: new Error(`OpenRouter authorization failed: ${description}`) });
|
||||
return;
|
||||
}
|
||||
|
||||
const code = requestUrl.searchParams.get("code");
|
||||
if (!code) {
|
||||
sendHtml(response, 400, oauthErrorHtml("OpenRouter returned no authorization code."));
|
||||
return;
|
||||
}
|
||||
claimed = true;
|
||||
|
||||
try {
|
||||
const result = await exchangeAuthorizationCode(code, verifier, signal);
|
||||
sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page."));
|
||||
finish({ credential: result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown token exchange error";
|
||||
sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message));
|
||||
finish({ error: error instanceof Error ? error : new Error(message) });
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, callbackHost, () => {
|
||||
server.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", (error) => finish({ error }));
|
||||
onAbort = () => finish({ error: new Error("Login cancelled") });
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
server.close();
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS);
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
finish({ error: new Error("Could not determine the OpenRouter OAuth callback port") });
|
||||
throw new Error("Could not determine the OpenRouter OAuth callback port");
|
||||
}
|
||||
|
||||
return {
|
||||
callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,
|
||||
credential,
|
||||
close: () => finish({ error: new Error("Login cancelled") }),
|
||||
};
|
||||
}
|
||||
|
||||
async function loginOpenRouter(interaction: AuthInteraction): Promise<OAuthCredential> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const callbackPath = `/oauth/callback/${crypto.randomUUID()}`;
|
||||
const callback = await startCallbackServer(callbackPath, verifier, interaction.signal);
|
||||
const authorizeUrl = new URL(AUTHORIZE_URL);
|
||||
authorizeUrl.search = new URLSearchParams({
|
||||
callback_url: callback.callbackUrl,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
}).toString();
|
||||
|
||||
interaction.notify({
|
||||
type: "progress",
|
||||
message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
|
||||
});
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url: authorizeUrl.toString(),
|
||||
instructions: "Complete sign-in in your browser.",
|
||||
});
|
||||
|
||||
try {
|
||||
return await callback.credential;
|
||||
} finally {
|
||||
callback.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const openRouterOAuth: OAuthAuth = {
|
||||
name: "OpenRouter OAuth",
|
||||
loginLabel: "Sign in with OpenRouter",
|
||||
login: loginOpenRouter,
|
||||
async refresh(credential) {
|
||||
return credential;
|
||||
},
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Radius gateway OAuth flow.
|
||||
*
|
||||
* Radius is a pi-messages gateway. OAuth endpoints are discovered from the
|
||||
* gateway (`/v1/oauth`); model catalog loading is owned by the Radius provider.
|
||||
* Radius is a pi-messages gateway. OAuth client APIs live on the configured
|
||||
* gateway; only the interactive browser authorization endpoint is discovered.
|
||||
* Model catalog loading is owned by the Radius provider.
|
||||
*
|
||||
* NOTE: This module uses node:http for the OAuth callback server.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
@@ -29,29 +30,23 @@ const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const TOKEN_EXPIRY_SKEW_MS = 60_000;
|
||||
const LOGIN_METHOD_BROWSER = "browser";
|
||||
const LOGIN_METHOD_DEVICE_CODE = "device-code";
|
||||
const OAUTH_CLIENT_ID = "pi-gateway";
|
||||
const OAUTH_SCOPE = "gateway offline_access";
|
||||
const OAUTH_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
type RadiusOAuthConfig = {
|
||||
issuer: string;
|
||||
type RadiusOAuthDiscovery = {
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
deviceAuthorizationEndpoint: string;
|
||||
deviceAuthorizationEventsEndpoint: string;
|
||||
verificationEndpoint: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
deviceCodeGrantType: string;
|
||||
};
|
||||
|
||||
type DeviceAuthorizationResponse = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri?: string;
|
||||
verification_uri_complete?: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
async function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig> {
|
||||
async function loadRadiusOAuthDiscovery(gateway: string): Promise<RadiusOAuthDiscovery> {
|
||||
const response = await fetch(new URL("/v1/oauth", gateway), {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
@@ -62,7 +57,11 @@ async function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as RadiusOAuthConfig;
|
||||
const discovery = (await response.json()) as Partial<RadiusOAuthDiscovery>;
|
||||
if (typeof discovery.authorizationEndpoint !== "string") {
|
||||
throw new Error(`Invalid Radius OAuth config from ${gateway}`);
|
||||
}
|
||||
return { authorizationEndpoint: discovery.authorizationEndpoint };
|
||||
}
|
||||
|
||||
class OAuthResponseError extends Error {
|
||||
@@ -100,13 +99,13 @@ async function readOAuthResponseError(response: Response, message: string): Prom
|
||||
}
|
||||
|
||||
async function requestOAuthToken(
|
||||
oauth: RadiusOAuthConfig,
|
||||
gateway: string,
|
||||
body: URLSearchParams,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(oauth.tokenEndpoint, {
|
||||
response = await fetch(new URL("/v1/oauth/token", gateway), {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
@@ -220,15 +219,19 @@ function startOAuthCallbackServer(
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {
|
||||
async function loginWithBrowser(
|
||||
gateway: string,
|
||||
authorizationEndpoint: string,
|
||||
interaction: AuthInteraction,
|
||||
): Promise<OAuthCredential> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const state = crypto.randomUUID();
|
||||
const authorizeUrl = new URL(oauth.authorizationEndpoint);
|
||||
const authorizeUrl = new URL(authorizationEndpoint);
|
||||
authorizeUrl.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: oauth.clientId,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: oauth.scope,
|
||||
scope: OAUTH_SCOPE,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
handoff: "url",
|
||||
@@ -252,10 +255,10 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInter
|
||||
throw new Error("OAuth callback did not complete.");
|
||||
}
|
||||
return await requestOAuthToken(
|
||||
oauth,
|
||||
gateway,
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: oauth.clientId,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
@@ -268,15 +271,15 @@ async function loginWithBrowser(oauth: RadiusOAuthConfig, interaction: AuthInter
|
||||
}
|
||||
|
||||
async function requestDeviceAuthorization(
|
||||
oauth: RadiusOAuthConfig,
|
||||
gateway: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<DeviceAuthorizationResponse> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(oauth.deviceAuthorizationEndpoint, {
|
||||
response = await fetch(new URL("/v1/oauth/device", gateway), {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ client_id: oauth.clientId, scope: oauth.scope }),
|
||||
body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -291,7 +294,7 @@ async function requestDeviceAuthorization(
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Partial<DeviceAuthorizationResponse>;
|
||||
if (!data.device_code || !data.user_code || !data.expires_in) {
|
||||
if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {
|
||||
throw new Error("Radius OAuth device authorization response is missing required fields");
|
||||
}
|
||||
|
||||
@@ -299,18 +302,17 @@ async function requestDeviceAuthorization(
|
||||
device_code: data.device_code,
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri,
|
||||
verification_uri_complete: data.verification_uri_complete,
|
||||
expires_in: data.expires_in,
|
||||
interval: data.interval,
|
||||
};
|
||||
}
|
||||
|
||||
async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthInteraction): Promise<OAuthCredential> {
|
||||
const device = await requestDeviceAuthorization(oauth, interaction.signal);
|
||||
async function loginWithDeviceCode(gateway: string, interaction: AuthInteraction): Promise<OAuthCredential> {
|
||||
const device = await requestDeviceAuthorization(gateway, interaction.signal);
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: device.user_code,
|
||||
verificationUri: device.verification_uri || oauth.verificationEndpoint,
|
||||
verificationUri: device.verification_uri,
|
||||
intervalSeconds: device.interval,
|
||||
expiresInSeconds: device.expires_in,
|
||||
});
|
||||
@@ -322,10 +324,10 @@ async function loginWithDeviceCode(oauth: RadiusOAuthConfig, interaction: AuthIn
|
||||
poll: async () => {
|
||||
try {
|
||||
const credentials = await requestOAuthToken(
|
||||
oauth,
|
||||
gateway,
|
||||
new URLSearchParams({
|
||||
grant_type: oauth.deviceCodeGrantType,
|
||||
client_id: oauth.clientId,
|
||||
grant_type: OAUTH_DEVICE_CODE_GRANT_TYPE,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
device_code: device.device_code,
|
||||
}),
|
||||
interaction.signal,
|
||||
@@ -364,7 +366,6 @@ export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
|
||||
name: options.name,
|
||||
|
||||
async login(interaction): Promise<OAuthCredential> {
|
||||
const oauth = await loadRadiusOAuthConfig(gateway);
|
||||
const loginMethod = await interaction.prompt({
|
||||
type: "select",
|
||||
message: `Sign in to ${options.name}:`,
|
||||
@@ -377,25 +378,22 @@ export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
|
||||
],
|
||||
});
|
||||
|
||||
let credential: OAuthCredential;
|
||||
if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
|
||||
credential = await loginWithDeviceCode(oauth, interaction);
|
||||
} else if (loginMethod === LOGIN_METHOD_BROWSER) {
|
||||
credential = await loginWithBrowser(oauth, interaction);
|
||||
} else {
|
||||
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
|
||||
return loginWithDeviceCode(gateway, interaction);
|
||||
}
|
||||
|
||||
return credential;
|
||||
if (loginMethod === LOGIN_METHOD_BROWSER) {
|
||||
const discovery = await loadRadiusOAuthDiscovery(gateway);
|
||||
return loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction);
|
||||
}
|
||||
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
|
||||
},
|
||||
|
||||
async refresh(credential, signal): Promise<OAuthCredential> {
|
||||
const oauth = await loadRadiusOAuthConfig(gateway);
|
||||
const refreshed = await requestOAuthToken(
|
||||
oauth,
|
||||
gateway,
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: oauth.clientId,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
refresh_token: credential.refresh,
|
||||
}),
|
||||
signal,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProviderEnv } from "../types.ts";
|
||||
import { formatThrownValue } from "../utils/diagnostics.ts";
|
||||
import type {
|
||||
ApiKeyAuth,
|
||||
ApiKeyCredential,
|
||||
@@ -22,12 +23,20 @@ export class ModelsError extends Error {
|
||||
readonly code: ModelsErrorCode;
|
||||
|
||||
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
super(withCauseDetail(message, options?.cause), options);
|
||||
this.name = "ModelsError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Callers surface `error.message` only, so keep the underlying reason in it. */
|
||||
function withCauseDetail(message: string, cause: unknown): string {
|
||||
if (cause === undefined || cause === null) return message;
|
||||
const detail = formatThrownValue(cause).trim();
|
||||
if (!detail || message.includes(detail)) return message;
|
||||
return `${message}: ${detail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth resolution shared by the `Models` and `ImagesModels` collections.
|
||||
* A stored credential owns the provider: ambient/env is consulted only when
|
||||
|
||||
@@ -3,6 +3,7 @@ import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
|
||||
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
|
||||
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
|
||||
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
|
||||
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
|
||||
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
|
||||
import { xaiOAuth } from "./auth/oauth/xai.ts";
|
||||
|
||||
@@ -12,6 +13,7 @@ export function registerBunOAuthFlows(): void {
|
||||
anthropic: () => anthropicOAuth,
|
||||
openaiCodex: () => openaiCodexOAuth,
|
||||
githubCopilot: () => githubCopilotOAuth,
|
||||
openrouter: () => openRouterOAuth,
|
||||
kimiCoding: () => kimiCodingOAuth,
|
||||
xai: () => xaiOAuth,
|
||||
radius: createRadiusOAuth,
|
||||
|
||||
@@ -26,6 +26,10 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
|
||||
import type { KnownProvider, ProviderEnv } from "./types.ts";
|
||||
import { getProviderEnvValue } from "./utils/provider-env.ts";
|
||||
|
||||
export const ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN";
|
||||
export const ANTHROPIC_OAUTH_TOKEN_ENV = "ANTHROPIC_OAUTH_TOKEN";
|
||||
export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
|
||||
|
||||
let cachedVertexAdcCredentialsExists: boolean | null = null;
|
||||
|
||||
function hasVertexAdcCredentials(env?: ProviderEnv): boolean {
|
||||
@@ -66,9 +70,10 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
||||
return ["COPILOT_GITHUB_TOKEN"];
|
||||
}
|
||||
|
||||
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
|
||||
// ANTHROPIC_AUTH_TOKEN participates in env discovery/status, but
|
||||
// getEnvApiKey() skips it because requests must pass it as Authorization: Bearer.
|
||||
if (provider === "anthropic") {
|
||||
return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"];
|
||||
return [ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV];
|
||||
}
|
||||
|
||||
const envMap: Record<string, string> = {
|
||||
@@ -139,7 +144,8 @@ export function getEnvApiKey(provider: string, env?: ProviderEnv): string | unde
|
||||
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined {
|
||||
const envKeys = findEnvKeys(provider, env);
|
||||
if (envKeys?.[0]) {
|
||||
return getProviderEnvValue(envKeys[0], env);
|
||||
const apiKeyEnv = provider === "anthropic" ? envKeys.find((key) => key !== ANTHROPIC_AUTH_TOKEN_ENV) : envKeys[0];
|
||||
if (apiKeyEnv) return getProviderEnvValue(apiKeyEnv, env);
|
||||
}
|
||||
|
||||
// Vertex AI supports either an explicit API key or Application Default Credentials.
|
||||
|
||||
@@ -230,6 +230,21 @@ export const IMAGE_MODELS = {
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"microsoft/mai-image-2.5-pro": {
|
||||
id: "microsoft/mai-image-2.5-pro",
|
||||
name: "Microsoft: MAI-Image-2.5 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image": {
|
||||
id: "openai/gpt-5-image",
|
||||
name: "OpenAI: GPT-5 Image",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Api, Model, ProviderId } from "./types.ts";
|
||||
|
||||
export type ModelGroups = Record<string, Record<string, object>>;
|
||||
|
||||
type ModelId<TGroups extends ModelGroups> = {
|
||||
[TApi in keyof TGroups]: keyof TGroups[TApi];
|
||||
}[keyof TGroups] &
|
||||
string;
|
||||
|
||||
type ModelApi<TGroups extends ModelGroups, TModelId extends ModelId<TGroups>> = {
|
||||
[TApi in keyof TGroups]: TModelId extends keyof TGroups[TApi] ? TApi : never;
|
||||
}[keyof TGroups] &
|
||||
Api;
|
||||
|
||||
export type ModelCatalog<TGroups extends ModelGroups, TProvider extends ProviderId> = {
|
||||
[TModelId in ModelId<TGroups>]: Model<ModelApi<TGroups, TModelId>> & {
|
||||
id: TModelId;
|
||||
provider: TProvider;
|
||||
};
|
||||
};
|
||||
|
||||
export function flattenModelCatalog<const TProvider extends ProviderId, const TGroups extends ModelGroups>(
|
||||
_provider: TProvider,
|
||||
groups: TGroups,
|
||||
): ModelCatalog<TGroups, TProvider> {
|
||||
return Object.assign({}, ...Object.values(groups)) as ModelCatalog<TGroups, TProvider>;
|
||||
}
|
||||
@@ -6,6 +6,11 @@ export interface ModelsStoreEntry {
|
||||
lastModified?: number;
|
||||
/** Unix timestamp of the last completed remote check. */
|
||||
checkedAt?: number;
|
||||
/**
|
||||
* Opaque validator from the remote catalog's ETag header, stored verbatim
|
||||
* (quotes included) and echoed back as If-None-Match.
|
||||
*/
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
/** Persistent model catalogs keyed by provider ID. */
|
||||
|
||||
@@ -39,7 +39,45 @@ import { XIAOMI_TOKEN_PLAN_SGP_MODELS } from "./providers/xiaomi-token-plan-sgp.
|
||||
import { ZAI_MODELS } from "./providers/zai.models.ts";
|
||||
import { ZAI_CODING_CN_MODELS } from "./providers/zai-coding-cn.models.ts";
|
||||
|
||||
export const MODELS = {
|
||||
export const MODELS: {
|
||||
readonly "amazon-bedrock": typeof AMAZON_BEDROCK_MODELS;
|
||||
readonly "ant-ling": typeof ANT_LING_MODELS;
|
||||
readonly "anthropic": typeof ANTHROPIC_MODELS;
|
||||
readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_MODELS;
|
||||
readonly "cerebras": typeof CEREBRAS_MODELS;
|
||||
readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_MODELS;
|
||||
readonly "cloudflare-workers-ai": typeof CLOUDFLARE_WORKERS_AI_MODELS;
|
||||
readonly "deepseek": typeof DEEPSEEK_MODELS;
|
||||
readonly "fireworks": typeof FIREWORKS_MODELS;
|
||||
readonly "github-copilot": typeof GITHUB_COPILOT_MODELS;
|
||||
readonly "google": typeof GOOGLE_MODELS;
|
||||
readonly "google-vertex": typeof GOOGLE_VERTEX_MODELS;
|
||||
readonly "groq": typeof GROQ_MODELS;
|
||||
readonly "huggingface": typeof HUGGINGFACE_MODELS;
|
||||
readonly "kimi-coding": typeof KIMI_CODING_MODELS;
|
||||
readonly "minimax": typeof MINIMAX_MODELS;
|
||||
readonly "minimax-cn": typeof MINIMAX_CN_MODELS;
|
||||
readonly "mistral": typeof MISTRAL_MODELS;
|
||||
readonly "moonshotai": typeof MOONSHOTAI_MODELS;
|
||||
readonly "moonshotai-cn": typeof MOONSHOTAI_CN_MODELS;
|
||||
readonly "nvidia": typeof NVIDIA_MODELS;
|
||||
readonly "openai": typeof OPENAI_MODELS;
|
||||
readonly "openai-codex": typeof OPENAI_CODEX_MODELS;
|
||||
readonly "opencode": typeof OPENCODE_MODELS;
|
||||
readonly "opencode-go": typeof OPENCODE_GO_MODELS;
|
||||
readonly "openrouter": typeof OPENROUTER_MODELS;
|
||||
readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_MODELS;
|
||||
readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_MODELS;
|
||||
readonly "together": typeof TOGETHER_MODELS;
|
||||
readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_MODELS;
|
||||
readonly "xai": typeof XAI_MODELS;
|
||||
readonly "xiaomi": typeof XIAOMI_MODELS;
|
||||
readonly "xiaomi-token-plan-ams": typeof XIAOMI_TOKEN_PLAN_AMS_MODELS;
|
||||
readonly "xiaomi-token-plan-cn": typeof XIAOMI_TOKEN_PLAN_CN_MODELS;
|
||||
readonly "xiaomi-token-plan-sgp": typeof XIAOMI_TOKEN_PLAN_SGP_MODELS;
|
||||
readonly "zai": typeof ZAI_MODELS;
|
||||
readonly "zai-coding-cn": typeof ZAI_CODING_CN_MODELS;
|
||||
} = {
|
||||
"amazon-bedrock": AMAZON_BEDROCK_MODELS,
|
||||
"ant-ling": ANT_LING_MODELS,
|
||||
"anthropic": ANTHROPIC_MODELS,
|
||||
@@ -77,4 +115,4 @@ export const MODELS = {
|
||||
"xiaomi-token-plan-sgp": XIAOMI_TOKEN_PLAN_SGP_MODELS,
|
||||
"zai": ZAI_MODELS,
|
||||
"zai-coding-cn": ZAI_CODING_CN_MODELS,
|
||||
} as const;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user