mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
Merge branch 'main' into pb/tui-status-coalesce
This commit is contained in:
commit
ac6f5006a9
216 changed files with 14479 additions and 8725 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -29,3 +29,4 @@ compaction-results/
|
|||
.opencode/
|
||||
syntax.jsonl
|
||||
out.jsonl
|
||||
pi-*.html
|
||||
|
|
|
|||
12
.pi/commands/review.md
Normal file
12
.pi/commands/review.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
description: Review a file for issues
|
||||
---
|
||||
Please review the following file for potential issues, bugs, or improvements:
|
||||
|
||||
$1
|
||||
|
||||
Focus on:
|
||||
- Logic errors
|
||||
- Edge cases
|
||||
- Code style
|
||||
- Performance concerns
|
||||
86
.pi/hooks/test-command.ts
Normal file
86
.pi/hooks/test-command.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Test hook demonstrating custom commands, message rendering, and before_agent_start.
|
||||
*/
|
||||
import type { BeforeAgentStartEvent, HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Box, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
// Track whether injection is enabled
|
||||
let injectEnabled = false;
|
||||
|
||||
// Register a custom message renderer for our "test-info" type
|
||||
pi.registerMessageRenderer("test-info", (message, options, theme) => {
|
||||
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
|
||||
const label = theme.fg("success", "[TEST INFO]");
|
||||
box.addChild(new Text(label, 0, 0));
|
||||
box.addChild(new Spacer(1));
|
||||
|
||||
const content =
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content.map((c) => (c.type === "text" ? c.text : "[image]")).join("");
|
||||
|
||||
box.addChild(new Text(theme.fg("text", content), 0, 0));
|
||||
|
||||
if (options.expanded && message.details) {
|
||||
box.addChild(new Spacer(1));
|
||||
box.addChild(new Text(theme.fg("dim", `Details: ${JSON.stringify(message.details)}`), 0, 0));
|
||||
}
|
||||
|
||||
return box;
|
||||
});
|
||||
|
||||
// Register /test-msg command
|
||||
pi.registerCommand("test-msg", {
|
||||
description: "Send a test custom message",
|
||||
handler: async () => {
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "test-info",
|
||||
content: "This is a test message with custom rendering!",
|
||||
display: true,
|
||||
details: { timestamp: Date.now(), source: "test-command hook" },
|
||||
},
|
||||
true, // triggerTurn: start agent run
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Register /test-hidden command
|
||||
pi.registerCommand("test-hidden", {
|
||||
description: "Send a hidden message (display: false)",
|
||||
handler: async (ctx) => {
|
||||
pi.sendMessage({
|
||||
customType: "test-info",
|
||||
content: "This message is in context but not displayed",
|
||||
display: false,
|
||||
});
|
||||
ctx.ui.notify("Sent hidden message (check session file)");
|
||||
},
|
||||
});
|
||||
|
||||
// Register /test-inject command to toggle before_agent_start injection
|
||||
pi.registerCommand("test-inject", {
|
||||
description: "Toggle context injection before agent starts",
|
||||
handler: async (ctx) => {
|
||||
injectEnabled = !injectEnabled;
|
||||
ctx.ui.notify(`Context injection ${injectEnabled ? "enabled" : "disabled"}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Demonstrate before_agent_start: inject context when enabled
|
||||
pi.on("before_agent_start", async (event: BeforeAgentStartEvent) => {
|
||||
if (!injectEnabled) return;
|
||||
|
||||
// Return a message to inject before the user's prompt
|
||||
return {
|
||||
message: {
|
||||
customType: "test-info",
|
||||
content: `[Injected context for prompt: "${event.prompt.slice(0, 50)}..."]`,
|
||||
display: true,
|
||||
details: { injectedAt: Date.now() },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
56
AGENTS.md
56
AGENTS.md
|
|
@ -30,7 +30,7 @@ When reading issues:
|
|||
|
||||
When creating issues:
|
||||
- Add `pkg:*` labels to indicate which package(s) the issue affects
|
||||
- Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:proxy`, `pkg:tui`, `pkg:web-ui`
|
||||
- Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui`
|
||||
- If an issue spans multiple packages, add all relevant labels
|
||||
|
||||
When closing issues via commit:
|
||||
|
|
@ -39,7 +39,7 @@ When closing issues via commit:
|
|||
|
||||
## Tools
|
||||
- GitHub CLI for issues/PRs
|
||||
- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:proxy, pkg:tui, pkg:web-ui
|
||||
- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui
|
||||
- TUI interaction: use tmux
|
||||
|
||||
## Style
|
||||
|
|
@ -49,43 +49,37 @@ When closing issues via commit:
|
|||
- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")
|
||||
|
||||
## Changelog
|
||||
Location: `packages/coding-agent/CHANGELOG.md`, `packages/ai/CHANGELOG.md`, `packages/tui/CHANGELOG.md`, pick the one relevant to the changes or ask user.
|
||||
Location: `packages/*/CHANGELOG.md` (each package has its own)
|
||||
|
||||
### Format
|
||||
Use these sections under `## [Unreleased]`:
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
|
||||
### Rules
|
||||
- New entries ALWAYS go under `## [Unreleased]` section
|
||||
- NEVER modify already-released version sections (e.g., `## [0.12.2]`)
|
||||
- Each version section is immutable once released
|
||||
- When releasing: rename `[Unreleased]` to the new version, then add a fresh empty `[Unreleased]` section
|
||||
|
||||
### Attribution format
|
||||
- **Internal changes (from issues)**: Reference issue only
|
||||
- Example: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))`
|
||||
- **External contributions (PRs from others)**: Reference PR and credit the contributor
|
||||
- Example: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))`
|
||||
- If a PR addresses an issue, reference both: `([#123](...issues/123), [#456](...pull/456) by [@user](...))` or just the PR if the issue context is clear from the description
|
||||
### Attribution
|
||||
- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))`
|
||||
- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))`
|
||||
|
||||
## Releasing
|
||||
|
||||
1. **Bump version** (all packages use lockstep versioning):
|
||||
1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md
|
||||
|
||||
2. **Run release script**:
|
||||
```bash
|
||||
npm run version:patch # For bug fixes
|
||||
npm run version:minor # For new features
|
||||
npm run version:major # For breaking changes
|
||||
npm run release:patch # Bug fixes
|
||||
npm run release:minor # New features
|
||||
npm run release:major # Breaking changes
|
||||
```
|
||||
|
||||
2. **Finalize CHANGELOG.md**: Change `[Unreleased]` to the new version with today's date (e.g., `## [0.12.12] - 2025-12-05`)
|
||||
|
||||
3. **Commit and tag**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Release v0.12.12"
|
||||
git tag v0.12.12
|
||||
git push origin main
|
||||
git push origin v0.12.12
|
||||
```
|
||||
|
||||
4. **Publish to npm**:
|
||||
```bash
|
||||
npm run publish
|
||||
```
|
||||
|
||||
5. **Add new [Unreleased] section** at top of CHANGELOG.md for next cycle, commit it
|
||||
The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections.
|
||||
|
||||
### Tool Usage
|
||||
**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads).
|
||||
60
README.md
60
README.md
|
|
@ -7,12 +7,11 @@ Tools for building AI agents and managing LLM deployments.
|
|||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[@mariozechner/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
|
||||
| **[@mariozechner/pi-agent](packages/agent)** | Agent runtime with tool calling and state management |
|
||||
| **[@mariozechner/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
|
||||
| **[@mariozechner/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
|
||||
| **[@mariozechner/pi-mom](packages/mom)** | Slack bot that delegates messages to the pi coding agent |
|
||||
| **[@mariozechner/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
|
||||
| **[@mariozechner/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces |
|
||||
| **[@mariozechner/pi-proxy](packages/proxy)** | CORS proxy for browser-based LLM API calls |
|
||||
| **[@mariozechner/pi-pods](packages/pods)** | CLI for managing vLLM deployments on GPU pods |
|
||||
|
||||
## Development
|
||||
|
|
@ -71,55 +70,18 @@ These commands:
|
|||
|
||||
### Publishing
|
||||
|
||||
Complete release process:
|
||||
```bash
|
||||
npm run release:patch # Bug fixes
|
||||
npm run release:minor # New features
|
||||
npm run release:major # Breaking changes
|
||||
```
|
||||
|
||||
1. **Add changes to CHANGELOG.md** (if changes affect coding-agent):
|
||||
```bash
|
||||
# Add your changes to the [Unreleased] section in packages/coding-agent/CHANGELOG.md
|
||||
# Always add new entries under [Unreleased], never under already-released versions
|
||||
```
|
||||
This handles version bump, CHANGELOG updates, commit, tag, publish, and push.
|
||||
|
||||
2. **Bump version** (all packages):
|
||||
```bash
|
||||
npm run version:patch # For bug fixes
|
||||
npm run version:minor # For new features
|
||||
npm run version:major # For breaking changes
|
||||
```
|
||||
|
||||
3. **Finalize CHANGELOG.md for release** (if changes affect coding-agent):
|
||||
```bash
|
||||
# Change [Unreleased] to the new version number with today's date
|
||||
# e.g., ## [0.7.16] - 2025-11-17
|
||||
# NEVER add entries to already-released version sections
|
||||
# Each version section is immutable once released
|
||||
```
|
||||
|
||||
4. **Commit and tag**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Release v0.7.16"
|
||||
git tag v0.7.16
|
||||
git push origin main
|
||||
git push origin v0.7.16
|
||||
```
|
||||
|
||||
5. **Publish to npm**:
|
||||
```bash
|
||||
npm run publish # Publish all packages to npm
|
||||
```
|
||||
|
||||
**NPM Token Setup**: Publishing requires a granular access token with "Bypass 2FA on publish" enabled.
|
||||
- Go to https://www.npmjs.com/settings/badlogic/tokens/
|
||||
- Create a new "Granular Access Token"
|
||||
- Select "Bypass 2FA on publish"
|
||||
- Tokens expire after 90 days, so regenerate when needed
|
||||
- Set the token: `npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN`
|
||||
|
||||
6. **Add new [Unreleased] section** (for next development cycle):
|
||||
```bash
|
||||
# Add a new [Unreleased] section at the top of CHANGELOG.md
|
||||
# Commit: git commit -am "Add [Unreleased] section"
|
||||
```
|
||||
**NPM Token Setup**: Requires a granular access token with "Bypass 2FA on publish" enabled.
|
||||
- Go to https://www.npmjs.com/settings/badlogic/tokens/
|
||||
- Create a new "Granular Access Token" with "Bypass 2FA on publish"
|
||||
- Set the token: `npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN`
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
149
package-lock.json
generated
149
package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"packages/web-ui/example"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.30.2",
|
||||
"get-east-asian-width": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -695,18 +696,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
|
|
@ -964,10 +953,6 @@
|
|||
"resolved": "packages/mom",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@mariozechner/pi-proxy": {
|
||||
"resolved": "packages/proxy",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@mariozechner/pi-tui": {
|
||||
"resolved": "packages/tui",
|
||||
"link": true
|
||||
|
|
@ -995,9 +980,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.86.tgz",
|
||||
"integrity": "sha512-hOkywnrkdFdVpsuaNsZWfEY7kc96eROV2DuMTTvGF15AZfwobzdG2w0eDlU5UBx3Lg/XlWUnqVT5zLUWyo5h6A==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz",
|
||||
"integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
|
|
@ -1011,23 +996,23 @@
|
|||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.86",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.86",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.86",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.86",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.86",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.86",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.86",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.86",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.86",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.86",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.86"
|
||||
"@napi-rs/canvas-android-arm64": "0.1.88",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.88",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.88",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.88",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.88",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.88",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.88",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.88",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.88",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.88"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.86.tgz",
|
||||
"integrity": "sha512-IjkZFKUr6GzMzzrawJaN3v+yY3Fvpa71e0DcbePfxWelFKnESIir+XUcdAbim29JOd0JE0/hQJdfUCb5t/Fjrw==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz",
|
||||
"integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1045,9 +1030,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.86.tgz",
|
||||
"integrity": "sha512-PUCxDq0wSSJbtaOqoKj3+t5tyDbtxWumziOTykdn3T839hu6koMaBFpGk9lXpsGaPNgyFpPqjxhtsPljBGnDHg==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz",
|
||||
"integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1065,9 +1050,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.86.tgz",
|
||||
"integrity": "sha512-rlCFLv4Rrg45qFZq7mysrKnsUbMhwdNg3YPuVfo9u4RkOqm7ooAJvdyDFxiqfSsJJTqupYqa9VQCUt8WKxKhNQ==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz",
|
||||
"integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1085,9 +1070,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.86.tgz",
|
||||
"integrity": "sha512-6xWwyMc9BlDBt+9XHN/GzUo3MozHta/2fxQHMb80x0K2zpZuAdDKUYHmYzx9dFWDY3SbPYnx6iRlQl6wxnwS1w==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz",
|
||||
"integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1105,9 +1090,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.86.tgz",
|
||||
"integrity": "sha512-r2OX3w50xHxrToTovOSQWwkVfSq752CUzH9dzlVXyr8UDKFV8dMjfa9hePXvAJhN3NBp4TkHcGx15QCdaCIwnA==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz",
|
||||
"integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1125,9 +1110,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.86.tgz",
|
||||
"integrity": "sha512-jbXuh8zVFUPw6a9SGpgc6EC+fRbGGyP1NFfeQiVqGLs6bN93ROtPLPL6MH9Bp6yt0CXUFallk2vgKdWDbmW+bw==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz",
|
||||
"integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1145,9 +1130,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.86.tgz",
|
||||
"integrity": "sha512-9IwHR2qbq2HceM9fgwyL7x37Jy3ptt1uxvikQEuWR0FisIx9QEdt7F3huljCky76aoouF2vSd0R2fHo3ESRoPw==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz",
|
||||
"integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1165,9 +1150,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.86.tgz",
|
||||
"integrity": "sha512-Jor+rhRN6ubix+D2QkNn9XlPPVAYl+2qFrkZ4oZN9UgtqIUZ+n+HljxhlkkDFRaX1mlxXOXPQjxaZg17zDSFcQ==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz",
|
||||
"integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1185,9 +1170,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.86.tgz",
|
||||
"integrity": "sha512-A28VTy91DbclopSGZ2tIon3p8hcVI1JhnNpDpJ5N9rYlUnVz1WQo4waEMh+FICTZF07O3coxBNZc4Vu4doFw7A==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz",
|
||||
"integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1205,9 +1190,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.86.tgz",
|
||||
"integrity": "sha512-q6G1YXUt3gBCAS2bcDMCaBL4y20di8eVVBi1XhjUqZSVyZZxxwIuRQHy31NlPJUCMiyNiMuc6zeI0uqgkWwAmA==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz",
|
||||
"integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1225,9 +1210,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.86",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.86.tgz",
|
||||
"integrity": "sha512-X0g46uRVgnvCM1cOjRXAOSFSG63ktUFIf/TIfbKCUc7QpmYUcHmSP9iR6DGOYfk+SggLsXoJCIhPTotYeZEAmg==",
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz",
|
||||
"integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -4008,16 +3993,6 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.2",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.2.tgz",
|
||||
"integrity": "sha512-o+avdUAD1v94oHkjGBhiMhBV4WBHxhbu0+CUVH78hhphKy/OKQLxtKjkmmNcrMlbYAhAbsM/9F+l3KnYxyD3Lg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-string": {
|
||||
"version": "0.0.9",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-string/-/html-parse-string-0.0.9.tgz",
|
||||
|
|
@ -4583,7 +4558,6 @@
|
|||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
|
||||
"integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
"lit-element": "^4.2.0",
|
||||
|
|
@ -5704,7 +5678,6 @@
|
|||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
|
|
@ -5733,8 +5706,7 @@
|
|||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
|
|
@ -5999,7 +5971,6 @@
|
|||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -6442,9 +6413,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
|
||||
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25 || ^4"
|
||||
|
|
@ -6636,22 +6607,6 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"packages/proxy": {
|
||||
"name": "@mariozechner/pi-proxy",
|
||||
"version": "0.30.2",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"hono": "^4.6.16"
|
||||
},
|
||||
"bin": {
|
||||
"pi-proxy": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@mariozechner/pi-tui",
|
||||
"version": "0.30.2",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
],
|
||||
"scripts": {
|
||||
"clean": "npm run clean --workspaces",
|
||||
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent-core && npm run build -w @mariozechner/pi-coding-agent && npm run build -w @mariozechner/pi-mom && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi-proxy && npm run build -w @mariozechner/pi",
|
||||
"dev": "concurrently --names \"ai,agent,coding-agent,mom,web-ui,tui,proxy\" --prefix-colors \"cyan,yellow,red,white,green,magenta,blue\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-agent-core\" \"npm run dev -w @mariozechner/pi-coding-agent\" \"npm run dev -w @mariozechner/pi-mom\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\" \"npm run dev -w @mariozechner/pi-proxy\"",
|
||||
"build": "npm run build -w @mariozechner/pi-tui && npm run build -w @mariozechner/pi-ai && npm run build -w @mariozechner/pi-agent-core && npm run build -w @mariozechner/pi-coding-agent && npm run build -w @mariozechner/pi-mom && npm run build -w @mariozechner/pi-web-ui && npm run build -w @mariozechner/pi",
|
||||
"dev": "concurrently --names \"ai,agent,coding-agent,mom,web-ui,tui\" --prefix-colors \"cyan,yellow,red,white,green,magenta\" \"npm run dev -w @mariozechner/pi-ai\" \"npm run dev -w @mariozechner/pi-agent-core\" \"npm run dev -w @mariozechner/pi-coding-agent\" \"npm run dev -w @mariozechner/pi-mom\" \"npm run dev -w @mariozechner/pi-web-ui\" \"npm run dev -w @mariozechner/pi-tui\"",
|
||||
"dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"npm run dev:tsc -w @mariozechner/pi-ai\" \"npm run dev:tsc -w @mariozechner/pi-web-ui\"",
|
||||
"check": "biome check --write . && tsgo --noEmit && npm run check -w @mariozechner/pi-web-ui",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
|
|
@ -20,6 +20,9 @@
|
|||
"prepublishOnly": "npm run clean && npm run build && npm run check",
|
||||
"publish": "npm run prepublishOnly && npm publish -ws --access public",
|
||||
"publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run",
|
||||
"release:patch": "node scripts/release.mjs patch",
|
||||
"release:minor": "node scripts/release.mjs minor",
|
||||
"release:major": "node scripts/release.mjs major",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -36,6 +39,7 @@
|
|||
},
|
||||
"version": "0.0.3",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.30.2",
|
||||
"get-east-asian-width": "^1.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
packages/agent/CHANGELOG.md
Normal file
38
packages/agent/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, and `AgentTransport` interface have been removed. Use the `streamFn` option directly for custom streaming implementations.
|
||||
|
||||
- **Agent options renamed**:
|
||||
- `transport` → removed (use `streamFn` instead)
|
||||
- `messageTransformer` → `convertToLlm`
|
||||
- `preprocessor` → `transformContext`
|
||||
|
||||
- **`AppMessage` renamed to `AgentMessage`**: All references to `AppMessage` have been renamed to `AgentMessage` for consistency.
|
||||
|
||||
- **`CustomMessages` renamed to `CustomAgentMessages`**: The declaration merging interface has been renamed.
|
||||
|
||||
- **`UserMessageWithAttachments` and `Attachment` types removed**: Attachment handling is now the responsibility of the `convertToLlm` function.
|
||||
|
||||
- **Agent loop moved from `@mariozechner/pi-ai`**: The `agentLoop`, `agentLoopContinue`, and related types have moved to this package. Import from `@mariozechner/pi-agent` instead.
|
||||
|
||||
### Added
|
||||
|
||||
- `streamFn` option on `Agent` for custom stream implementations. Default uses `streamSimple` from pi-ai.
|
||||
|
||||
- `streamProxy()` utility function for browser apps that need to proxy LLM calls through a backend server. Replaces the removed `AppTransport`.
|
||||
|
||||
- `getApiKey` option for dynamic API key resolution (useful for expiring OAuth tokens like GitHub Copilot).
|
||||
|
||||
- `agentLoop()` and `agentLoopContinue()` low-level functions for running the agent loop without the `Agent` class wrapper.
|
||||
|
||||
- New exported types: `AgentLoopConfig`, `AgentContext`, `AgentTool`, `AgentToolResult`, `AgentToolUpdateCallback`, `StreamFn`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Agent` constructor now has all options optional (empty options use defaults).
|
||||
|
||||
- `queueMessage()` is now synchronous (no longer returns a Promise).
|
||||
|
|
@ -1,194 +1,365 @@
|
|||
# @mariozechner/pi-agent-core
|
||||
# @mariozechner/pi-agent
|
||||
|
||||
Stateful agent abstraction with transport layer for LLM interactions. Provides a reactive `Agent` class that manages conversation state, emits granular events, and supports pluggable transports for different deployment scenarios.
|
||||
Stateful agent with tool execution and event streaming. Built on `@mariozechner/pi-ai`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @mariozechner/pi-agent-core
|
||||
npm install @mariozechner/pi-agent
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Agent, ProviderTransport } from '@mariozechner/pi-agent-core';
|
||||
import { getModel } from '@mariozechner/pi-ai';
|
||||
import { Agent } from "@mariozechner/pi-agent";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
|
||||
// Create agent with direct provider transport
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
initialState: {
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
model: getModel('anthropic', 'claude-sonnet-4-20250514'),
|
||||
thinkingLevel: 'medium',
|
||||
tools: []
|
||||
}
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model: getModel("anthropic", "claude-sonnet-4-20250514"),
|
||||
},
|
||||
});
|
||||
|
||||
// Subscribe to events for reactive UI updates
|
||||
agent.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case 'message_update':
|
||||
// Stream text to UI
|
||||
const content = event.message.content;
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') console.log(block.text);
|
||||
}
|
||||
break;
|
||||
case 'tool_execution_start':
|
||||
console.log(`Calling ${event.toolName}...`);
|
||||
break;
|
||||
case 'tool_execution_update':
|
||||
// Stream tool output (e.g., bash stdout)
|
||||
console.log('Progress:', event.partialResult.content);
|
||||
break;
|
||||
case 'tool_execution_end':
|
||||
console.log(`Result:`, event.result.content);
|
||||
break;
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
// Stream just the new text chunk
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
// Send a prompt
|
||||
await agent.prompt('Hello, world!');
|
||||
|
||||
// Access conversation state
|
||||
console.log(agent.state.messages);
|
||||
await agent.prompt("Hello!");
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Agent State
|
||||
### AgentMessage vs LLM Message
|
||||
|
||||
The `Agent` maintains reactive state:
|
||||
The agent works with `AgentMessage`, a flexible type that can include:
|
||||
- Standard LLM messages (`user`, `assistant`, `toolResult`)
|
||||
- Custom app-specific message types via declaration merging
|
||||
|
||||
LLMs only understand `user`, `assistant`, and `toolResult`. The `convertToLlm` function bridges this gap by filtering and transforming messages before each LLM call.
|
||||
|
||||
### Message Flow
|
||||
|
||||
```
|
||||
AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM
|
||||
(optional) (required)
|
||||
```
|
||||
|
||||
1. **transformContext**: Prune old messages, inject external context
|
||||
2. **convertToLlm**: Filter out UI-only messages, convert custom types to LLM format
|
||||
|
||||
## Event Flow
|
||||
|
||||
The agent emits events for UI updates. Understanding the event sequence helps build responsive interfaces.
|
||||
|
||||
### prompt() Event Sequence
|
||||
|
||||
When you call `prompt("Hello")`:
|
||||
|
||||
```
|
||||
prompt("Hello")
|
||||
├─ agent_start
|
||||
├─ turn_start
|
||||
├─ message_start { message: userMessage } // Your prompt
|
||||
├─ message_end { message: userMessage }
|
||||
├─ message_start { message: assistantMessage } // LLM starts responding
|
||||
├─ message_update { message: partial... } // Streaming chunks
|
||||
├─ message_update { message: partial... }
|
||||
├─ message_end { message: assistantMessage } // Complete response
|
||||
├─ turn_end { message, toolResults: [] }
|
||||
└─ agent_end { messages: [...] }
|
||||
```
|
||||
|
||||
### With Tool Calls
|
||||
|
||||
If the assistant calls tools, the loop continues:
|
||||
|
||||
```
|
||||
prompt("Read config.json")
|
||||
├─ agent_start
|
||||
├─ turn_start
|
||||
├─ message_start/end { userMessage }
|
||||
├─ message_start { assistantMessage with toolCall }
|
||||
├─ message_update...
|
||||
├─ message_end { assistantMessage }
|
||||
├─ tool_execution_start { toolCallId, toolName, args }
|
||||
├─ tool_execution_update { partialResult } // If tool streams
|
||||
├─ tool_execution_end { toolCallId, result }
|
||||
├─ message_start/end { toolResultMessage }
|
||||
├─ turn_end { message, toolResults: [toolResult] }
|
||||
│
|
||||
├─ turn_start // Next turn
|
||||
├─ message_start { assistantMessage } // LLM responds to tool result
|
||||
├─ message_update...
|
||||
├─ message_end
|
||||
├─ turn_end
|
||||
└─ agent_end
|
||||
```
|
||||
|
||||
### continue() Event Sequence
|
||||
|
||||
`continue()` resumes from existing context without adding a new message. Use it for retries after errors.
|
||||
|
||||
```typescript
|
||||
// After an error, retry from current state
|
||||
await agent.continue();
|
||||
```
|
||||
|
||||
The last message in context must be `user` or `toolResult` (not `assistant`).
|
||||
|
||||
### Event Types
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `agent_start` | Agent begins processing |
|
||||
| `agent_end` | Agent completes with all new messages |
|
||||
| `turn_start` | New turn begins (one LLM call + tool executions) |
|
||||
| `turn_end` | Turn completes with assistant message and tool results |
|
||||
| `message_start` | Any message begins (user, assistant, toolResult) |
|
||||
| `message_update` | **Assistant only.** Includes `assistantMessageEvent` with delta |
|
||||
| `message_end` | Message completes |
|
||||
| `tool_execution_start` | Tool begins |
|
||||
| `tool_execution_update` | Tool streams progress |
|
||||
| `tool_execution_end` | Tool completes |
|
||||
|
||||
## Agent Options
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
// Initial state
|
||||
initialState: {
|
||||
systemPrompt: string,
|
||||
model: Model<any>,
|
||||
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh",
|
||||
tools: AgentTool<any>[],
|
||||
messages: AgentMessage[],
|
||||
},
|
||||
|
||||
// Convert AgentMessage[] to LLM Message[] (required for custom message types)
|
||||
convertToLlm: (messages) => messages.filter(...),
|
||||
|
||||
// Transform context before convertToLlm (for pruning, compaction)
|
||||
transformContext: async (messages, signal) => pruneOldMessages(messages),
|
||||
|
||||
// How to handle queued messages: "one-at-a-time" (default) or "all"
|
||||
queueMode: "one-at-a-time",
|
||||
|
||||
// Custom stream function (for proxy backends)
|
||||
streamFn: streamProxy,
|
||||
|
||||
// Dynamic API key resolution (for expiring OAuth tokens)
|
||||
getApiKey: async (provider) => refreshToken(),
|
||||
});
|
||||
```
|
||||
|
||||
## Agent State
|
||||
|
||||
```typescript
|
||||
interface AgentState {
|
||||
systemPrompt: string;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel; // 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: AgentTool<any>[];
|
||||
messages: AppMessage[];
|
||||
messages: AgentMessage[];
|
||||
isStreaming: boolean;
|
||||
streamMessage: Message | null;
|
||||
streamMessage: AgentMessage | null; // Current partial during streaming
|
||||
pendingToolCalls: Set<string>;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Events
|
||||
Access via `agent.state`. During streaming, `streamMessage` contains the partial assistant message.
|
||||
|
||||
Events provide fine-grained lifecycle information:
|
||||
## Methods
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `agent_start` | Agent begins processing |
|
||||
| `agent_end` | Agent completes, contains all generated messages |
|
||||
| `turn_start` | New turn begins (one LLM response + tool executions) |
|
||||
| `turn_end` | Turn completes with assistant message and tool results |
|
||||
| `message_start` | Message begins (user, assistant, or toolResult) |
|
||||
| `message_update` | Assistant message streaming update |
|
||||
| `message_end` | Message completes |
|
||||
| `tool_execution_start` | Tool begins execution |
|
||||
| `tool_execution_update` | Tool streams progress (e.g., bash output) |
|
||||
| `tool_execution_end` | Tool completes with result |
|
||||
|
||||
### Transports
|
||||
|
||||
Transports abstract LLM communication:
|
||||
|
||||
- **`ProviderTransport`**: Direct API calls using `@mariozechner/pi-ai`
|
||||
- **`AppTransport`**: Proxy through a backend server (for browser apps)
|
||||
### Prompting
|
||||
|
||||
```typescript
|
||||
// Direct provider access (Node.js)
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY
|
||||
})
|
||||
});
|
||||
// Text prompt
|
||||
await agent.prompt("Hello");
|
||||
|
||||
// Via proxy (browser)
|
||||
const agent = new Agent({
|
||||
transport: new AppTransport({
|
||||
endpoint: '/api/agent',
|
||||
headers: { 'Authorization': 'Bearer ...' }
|
||||
})
|
||||
// With images
|
||||
await agent.prompt("What's in this image?", [
|
||||
{ type: "image", data: base64Data, mimeType: "image/jpeg" }
|
||||
]);
|
||||
|
||||
// AgentMessage directly
|
||||
await agent.prompt({ role: "user", content: "Hello", timestamp: Date.now() });
|
||||
|
||||
// Continue from current context (last message must be user or toolResult)
|
||||
await agent.continue();
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
agent.setSystemPrompt("New prompt");
|
||||
agent.setModel(getModel("openai", "gpt-4o"));
|
||||
agent.setThinkingLevel("medium");
|
||||
agent.setTools([myTool]);
|
||||
agent.replaceMessages(newMessages);
|
||||
agent.appendMessage(message);
|
||||
agent.clearMessages();
|
||||
agent.reset(); // Clear everything
|
||||
```
|
||||
|
||||
### Control
|
||||
|
||||
```typescript
|
||||
agent.abort(); // Cancel current operation
|
||||
await agent.waitForIdle(); // Wait for completion
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
```typescript
|
||||
const unsubscribe = agent.subscribe((event) => {
|
||||
console.log(event.type);
|
||||
});
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## Message Queue
|
||||
|
||||
Queue messages to inject at the next turn:
|
||||
Queue messages to inject during tool execution (for user interruptions):
|
||||
|
||||
```typescript
|
||||
// Queue mode: 'all' or 'one-at-a-time'
|
||||
agent.setQueueMode('one-at-a-time');
|
||||
agent.setQueueMode("one-at-a-time");
|
||||
|
||||
// Queue a message while agent is streaming
|
||||
await agent.queueMessage({
|
||||
role: 'user',
|
||||
content: 'Additional context...',
|
||||
timestamp: Date.now()
|
||||
// While agent is running tools
|
||||
agent.queueMessage({
|
||||
role: "user",
|
||||
content: "Stop! Do this instead.",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
```
|
||||
|
||||
## Attachments
|
||||
|
||||
User messages can include attachments:
|
||||
|
||||
```typescript
|
||||
await agent.prompt('What is in this image?', [{
|
||||
id: 'img1',
|
||||
type: 'image',
|
||||
fileName: 'photo.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 102400,
|
||||
content: base64ImageData
|
||||
}]);
|
||||
```
|
||||
When queued messages are detected after a tool completes:
|
||||
1. Remaining tools are skipped with error results
|
||||
2. Queued message is injected
|
||||
3. LLM responds to the interruption
|
||||
|
||||
## Custom Message Types
|
||||
|
||||
Extend `AppMessage` for app-specific messages via declaration merging:
|
||||
Extend `AgentMessage` via declaration merging:
|
||||
|
||||
```typescript
|
||||
declare module '@mariozechner/pi-agent-core' {
|
||||
interface CustomMessages {
|
||||
artifact: { role: 'artifact'; code: string; language: string };
|
||||
declare module "@mariozechner/pi-agent" {
|
||||
interface CustomAgentMessages {
|
||||
notification: { role: "notification"; text: string; timestamp: number };
|
||||
}
|
||||
}
|
||||
|
||||
// Now AppMessage includes your custom type
|
||||
const msg: AppMessage = { role: 'artifact', code: '...', language: 'typescript' };
|
||||
// Now valid
|
||||
const msg: AgentMessage = { role: "notification", text: "Info", timestamp: Date.now() };
|
||||
```
|
||||
|
||||
## API Reference
|
||||
Handle custom types in `convertToLlm`:
|
||||
|
||||
### Agent Methods
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
convertToLlm: (messages) => messages.flatMap(m => {
|
||||
if (m.role === "notification") return []; // Filter out
|
||||
return [m];
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `prompt(text, attachments?)` | Send a user prompt |
|
||||
| `continue()` | Continue from current context (for retry after overflow) |
|
||||
| `abort()` | Abort current operation |
|
||||
| `waitForIdle()` | Returns promise that resolves when agent is idle |
|
||||
| `reset()` | Clear all messages and state |
|
||||
| `subscribe(fn)` | Subscribe to events, returns unsubscribe function |
|
||||
| `queueMessage(msg)` | Queue message for next turn |
|
||||
| `clearMessageQueue()` | Clear queued messages |
|
||||
## Tools
|
||||
|
||||
### State Mutators
|
||||
Define tools using `AgentTool`:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `setSystemPrompt(v)` | Update system prompt |
|
||||
| `setModel(m)` | Switch model |
|
||||
| `setThinkingLevel(l)` | Set reasoning level |
|
||||
| `setQueueMode(m)` | Set queue mode ('all' or 'one-at-a-time') |
|
||||
| `setTools(t)` | Update available tools |
|
||||
| `replaceMessages(ms)` | Replace all messages |
|
||||
| `appendMessage(m)` | Append a message |
|
||||
| `clearMessages()` | Clear all messages |
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
const readFileTool: AgentTool = {
|
||||
name: "read_file",
|
||||
label: "Read File", // For UI display
|
||||
description: "Read a file's contents",
|
||||
parameters: Type.Object({
|
||||
path: Type.String({ description: "File path" }),
|
||||
}),
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
const content = await fs.readFile(params.path, "utf-8");
|
||||
|
||||
// Optional: stream progress
|
||||
onUpdate?.({ content: [{ type: "text", text: "Reading..." }], details: {} });
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: content }],
|
||||
details: { path: params.path, size: content.length },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
agent.setTools([readFileTool]);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Throw an error** when a tool fails. Do not return error messages as content.
|
||||
|
||||
```typescript
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
if (!fs.existsSync(params.path)) {
|
||||
throw new Error(`File not found: ${params.path}`);
|
||||
}
|
||||
// Return content only on success
|
||||
return { content: [{ type: "text", text: "..." }] };
|
||||
}
|
||||
```
|
||||
|
||||
Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`.
|
||||
|
||||
## Proxy Usage
|
||||
|
||||
For browser apps that proxy through a backend:
|
||||
|
||||
```typescript
|
||||
import { Agent, streamProxy } from "@mariozechner/pi-agent";
|
||||
|
||||
const agent = new Agent({
|
||||
streamFn: (model, context, options) =>
|
||||
streamProxy(model, context, {
|
||||
...options,
|
||||
authToken: "...",
|
||||
proxyUrl: "https://your-server.com",
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Low-Level API
|
||||
|
||||
For direct control without the Agent class:
|
||||
|
||||
```typescript
|
||||
import { agentLoop, agentLoopContinue } from "@mariozechner/pi-agent";
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: getModel("openai", "gpt-4o"),
|
||||
convertToLlm: (msgs) => msgs.filter(m => ["user", "assistant", "toolResult"].includes(m.role)),
|
||||
};
|
||||
|
||||
const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };
|
||||
|
||||
for await (const event of agentLoop([userMessage], context, config)) {
|
||||
console.log(event.type);
|
||||
}
|
||||
|
||||
// Continue from existing context
|
||||
for await (const event of agentLoopContinue(context, config)) {
|
||||
console.log(event.type);
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,52 @@
|
|||
import { streamSimple } from "../stream.js";
|
||||
import type { AssistantMessage, Context, Message, ToolResultMessage, UserMessage } from "../types.js";
|
||||
import { EventStream } from "../utils/event-stream.js";
|
||||
import { validateToolArguments } from "../utils/validation.js";
|
||||
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, AgentToolResult, QueuedMessage } from "./types.js";
|
||||
/**
|
||||
* Agent loop that works with AgentMessage throughout.
|
||||
* Transforms to Message[] only at the LLM call boundary.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
EventStream,
|
||||
streamSimple,
|
||||
type ToolResultMessage,
|
||||
validateToolArguments,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentLoopConfig,
|
||||
AgentMessage,
|
||||
AgentTool,
|
||||
AgentToolResult,
|
||||
StreamFn,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Start an agent loop with a new user message.
|
||||
* Start an agent loop with a new prompt message.
|
||||
* The prompt is added to the context and events are emitted for it.
|
||||
*/
|
||||
export function agentLoop(
|
||||
prompt: UserMessage,
|
||||
prompts: AgentMessage[],
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
signal?: AbortSignal,
|
||||
streamFn?: typeof streamSimple,
|
||||
): EventStream<AgentEvent, AgentContext["messages"]> {
|
||||
streamFn?: StreamFn,
|
||||
): EventStream<AgentEvent, AgentMessage[]> {
|
||||
const stream = createAgentStream();
|
||||
|
||||
(async () => {
|
||||
const newMessages: AgentContext["messages"] = [prompt];
|
||||
const newMessages: AgentMessage[] = [...prompts];
|
||||
const currentContext: AgentContext = {
|
||||
...context,
|
||||
messages: [...context.messages, prompt],
|
||||
messages: [...context.messages, ...prompts],
|
||||
};
|
||||
|
||||
stream.push({ type: "agent_start" });
|
||||
stream.push({ type: "turn_start" });
|
||||
stream.push({ type: "message_start", message: prompt });
|
||||
stream.push({ type: "message_end", message: prompt });
|
||||
for (const prompt of prompts) {
|
||||
stream.push({ type: "message_start", message: prompt });
|
||||
stream.push({ type: "message_end", message: prompt });
|
||||
}
|
||||
|
||||
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
||||
})();
|
||||
|
|
@ -37,33 +56,34 @@ export function agentLoop(
|
|||
|
||||
/**
|
||||
* Continue an agent loop from the current context without adding a new message.
|
||||
* Used for retry after overflow - context already has user message or tool results.
|
||||
* Throws if the last message is not a user message or tool result.
|
||||
* Used for retries - context already has user message or tool results.
|
||||
*
|
||||
* **Important:** The last message in context must convert to a `user` or `toolResult` message
|
||||
* via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
|
||||
* This cannot be validated here since `convertToLlm` is only called once per turn.
|
||||
*/
|
||||
export function agentLoopContinue(
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
signal?: AbortSignal,
|
||||
streamFn?: typeof streamSimple,
|
||||
): EventStream<AgentEvent, AgentContext["messages"]> {
|
||||
// Validate that we can continue from this context
|
||||
const lastMessage = context.messages[context.messages.length - 1];
|
||||
if (!lastMessage) {
|
||||
streamFn?: StreamFn,
|
||||
): EventStream<AgentEvent, AgentMessage[]> {
|
||||
if (context.messages.length === 0) {
|
||||
throw new Error("Cannot continue: no messages in context");
|
||||
}
|
||||
if (lastMessage.role !== "user" && lastMessage.role !== "toolResult") {
|
||||
throw new Error(`Cannot continue from message role: ${lastMessage.role}. Expected 'user' or 'toolResult'.`);
|
||||
|
||||
if (context.messages[context.messages.length - 1].role === "assistant") {
|
||||
throw new Error("Cannot continue from message role: assistant");
|
||||
}
|
||||
|
||||
const stream = createAgentStream();
|
||||
|
||||
(async () => {
|
||||
const newMessages: AgentContext["messages"] = [];
|
||||
const newMessages: AgentMessage[] = [];
|
||||
const currentContext: AgentContext = { ...context };
|
||||
|
||||
stream.push({ type: "agent_start" });
|
||||
stream.push({ type: "turn_start" });
|
||||
// No user message events - we're continuing from existing context
|
||||
|
||||
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
||||
})();
|
||||
|
|
@ -71,28 +91,28 @@ export function agentLoopContinue(
|
|||
return stream;
|
||||
}
|
||||
|
||||
function createAgentStream(): EventStream<AgentEvent, AgentContext["messages"]> {
|
||||
return new EventStream<AgentEvent, AgentContext["messages"]>(
|
||||
function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
|
||||
return new EventStream<AgentEvent, AgentMessage[]>(
|
||||
(event: AgentEvent) => event.type === "agent_end",
|
||||
(event: AgentEvent) => (event.type === "agent_end" ? event.messages : []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared loop logic for both agentLoop and agentLoopContinue.
|
||||
* Main loop logic shared by agentLoop and agentLoopContinue.
|
||||
*/
|
||||
async function runLoop(
|
||||
currentContext: AgentContext,
|
||||
newMessages: AgentContext["messages"],
|
||||
newMessages: AgentMessage[],
|
||||
config: AgentLoopConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
stream: EventStream<AgentEvent, AgentContext["messages"]>,
|
||||
streamFn?: typeof streamSimple,
|
||||
stream: EventStream<AgentEvent, AgentMessage[]>,
|
||||
streamFn?: StreamFn,
|
||||
): Promise<void> {
|
||||
let hasMoreToolCalls = true;
|
||||
let firstTurn = true;
|
||||
let queuedMessages: QueuedMessage<any>[] = (await config.getQueuedMessages?.()) || [];
|
||||
let queuedAfterTools: QueuedMessage<any>[] | null = null;
|
||||
let queuedMessages: AgentMessage[] = (await config.getQueuedMessages?.()) || [];
|
||||
let queuedAfterTools: AgentMessage[] | null = null;
|
||||
|
||||
while (hasMoreToolCalls || queuedMessages.length > 0) {
|
||||
if (!firstTurn) {
|
||||
|
|
@ -101,15 +121,13 @@ async function runLoop(
|
|||
firstTurn = false;
|
||||
}
|
||||
|
||||
// Process queued messages first (inject before next assistant response)
|
||||
// Process queued messages (inject before next assistant response)
|
||||
if (queuedMessages.length > 0) {
|
||||
for (const { original, llm } of queuedMessages) {
|
||||
stream.push({ type: "message_start", message: original });
|
||||
stream.push({ type: "message_end", message: original });
|
||||
if (llm) {
|
||||
currentContext.messages.push(llm);
|
||||
newMessages.push(llm);
|
||||
}
|
||||
for (const message of queuedMessages) {
|
||||
stream.push({ type: "message_start", message });
|
||||
stream.push({ type: "message_end", message });
|
||||
currentContext.messages.push(message);
|
||||
newMessages.push(message);
|
||||
}
|
||||
queuedMessages = [];
|
||||
}
|
||||
|
|
@ -119,7 +137,6 @@ async function runLoop(
|
|||
newMessages.push(message);
|
||||
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
// Stop the loop on error or abort
|
||||
stream.push({ type: "turn_end", message, toolResults: [] });
|
||||
stream.push({ type: "agent_end", messages: newMessages });
|
||||
stream.end(newMessages);
|
||||
|
|
@ -132,7 +149,6 @@ async function runLoop(
|
|||
|
||||
const toolResults: ToolResultMessage[] = [];
|
||||
if (hasMoreToolCalls) {
|
||||
// Execute tool calls
|
||||
const toolExecution = await executeToolCalls(
|
||||
currentContext.tools,
|
||||
message,
|
||||
|
|
@ -142,10 +158,14 @@ async function runLoop(
|
|||
);
|
||||
toolResults.push(...toolExecution.toolResults);
|
||||
queuedAfterTools = toolExecution.queuedMessages ?? null;
|
||||
currentContext.messages.push(...toolResults);
|
||||
newMessages.push(...toolResults);
|
||||
|
||||
for (const result of toolResults) {
|
||||
currentContext.messages.push(result);
|
||||
newMessages.push(result);
|
||||
}
|
||||
}
|
||||
stream.push({ type: "turn_end", message, toolResults: toolResults });
|
||||
|
||||
stream.push({ type: "turn_end", message, toolResults });
|
||||
|
||||
// Get queued messages after turn completes
|
||||
if (queuedAfterTools && queuedAfterTools.length > 0) {
|
||||
|
|
@ -160,41 +180,44 @@ async function runLoop(
|
|||
stream.end(newMessages);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
/**
|
||||
* Stream an assistant response from the LLM.
|
||||
* This is where AgentMessage[] gets transformed to Message[] for the LLM.
|
||||
*/
|
||||
async function streamAssistantResponse(
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
stream: EventStream<AgentEvent, AgentContext["messages"]>,
|
||||
streamFn?: typeof streamSimple,
|
||||
stream: EventStream<AgentEvent, AgentMessage[]>,
|
||||
streamFn?: StreamFn,
|
||||
): Promise<AssistantMessage> {
|
||||
// Convert AgentContext to Context for streamSimple
|
||||
// Use a copy of messages to avoid mutating the original context
|
||||
const processedMessages = config.preprocessor
|
||||
? await config.preprocessor(context.messages, signal)
|
||||
: [...context.messages];
|
||||
const processedContext: Context = {
|
||||
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
|
||||
let messages = context.messages;
|
||||
if (config.transformContext) {
|
||||
messages = await config.transformContext(messages, signal);
|
||||
}
|
||||
|
||||
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
|
||||
const llmMessages = await config.convertToLlm(messages);
|
||||
|
||||
// Build LLM context
|
||||
const llmContext: Context = {
|
||||
systemPrompt: context.systemPrompt,
|
||||
messages: [...processedMessages].map((m) => {
|
||||
if (m.role === "toolResult") {
|
||||
// biome-ignore lint/correctness/noUnusedVariables: fine here
|
||||
const { details, ...rest } = m;
|
||||
return rest;
|
||||
} else {
|
||||
return m;
|
||||
}
|
||||
}),
|
||||
tools: context.tools, // AgentTool extends Tool, so this works
|
||||
messages: llmMessages,
|
||||
tools: context.tools,
|
||||
};
|
||||
|
||||
// Use custom stream function if provided, otherwise use default streamSimple
|
||||
const streamFunction = streamFn || streamSimple;
|
||||
|
||||
// Resolve API key for every assistant response (important for expiring tokens)
|
||||
// Resolve API key (important for expiring tokens)
|
||||
const resolvedApiKey =
|
||||
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
|
||||
|
||||
const response = await streamFunction(config.model, processedContext, { ...config, apiKey: resolvedApiKey, signal });
|
||||
const response = await streamFunction(config.model, llmContext, {
|
||||
...config,
|
||||
apiKey: resolvedApiKey,
|
||||
signal,
|
||||
});
|
||||
|
||||
let partialMessage: AssistantMessage | null = null;
|
||||
let addedPartial = false;
|
||||
|
|
@ -220,7 +243,11 @@ async function streamAssistantResponse(
|
|||
if (partialMessage) {
|
||||
partialMessage = event.partial;
|
||||
context.messages[context.messages.length - 1] = partialMessage;
|
||||
stream.push({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } });
|
||||
stream.push({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: event,
|
||||
message: { ...partialMessage },
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -244,16 +271,19 @@ async function streamAssistantResponse(
|
|||
return await response.result();
|
||||
}
|
||||
|
||||
async function executeToolCalls<T>(
|
||||
tools: AgentTool<any, T>[] | undefined,
|
||||
/**
|
||||
* Execute tool calls from an assistant message.
|
||||
*/
|
||||
async function executeToolCalls(
|
||||
tools: AgentTool<any>[] | undefined,
|
||||
assistantMessage: AssistantMessage,
|
||||
signal: AbortSignal | undefined,
|
||||
stream: EventStream<AgentEvent, Message[]>,
|
||||
stream: EventStream<AgentEvent, AgentMessage[]>,
|
||||
getQueuedMessages?: AgentLoopConfig["getQueuedMessages"],
|
||||
): Promise<{ toolResults: ToolResultMessage<T>[]; queuedMessages?: QueuedMessage<any>[] }> {
|
||||
): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: AgentMessage[] }> {
|
||||
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
|
||||
const results: ToolResultMessage<any>[] = [];
|
||||
let queuedMessages: QueuedMessage<any>[] | undefined;
|
||||
const results: ToolResultMessage[] = [];
|
||||
let queuedMessages: AgentMessage[] | undefined;
|
||||
|
||||
for (let index = 0; index < toolCalls.length; index++) {
|
||||
const toolCall = toolCalls[index];
|
||||
|
|
@ -266,16 +296,14 @@ async function executeToolCalls<T>(
|
|||
args: toolCall.arguments,
|
||||
});
|
||||
|
||||
let result: AgentToolResult<T>;
|
||||
let result: AgentToolResult<any>;
|
||||
let isError = false;
|
||||
|
||||
try {
|
||||
if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
|
||||
|
||||
// Validate arguments using shared validation function
|
||||
const validatedArgs = validateToolArguments(tool, toolCall);
|
||||
|
||||
// Execute with validated, typed arguments, passing update callback
|
||||
result = await tool.execute(toolCall.id, validatedArgs, signal, (partialResult) => {
|
||||
stream.push({
|
||||
type: "tool_execution_update",
|
||||
|
|
@ -288,7 +316,7 @@ async function executeToolCalls<T>(
|
|||
} catch (e) {
|
||||
result = {
|
||||
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
||||
details: {} as T,
|
||||
details: {},
|
||||
};
|
||||
isError = true;
|
||||
}
|
||||
|
|
@ -301,7 +329,7 @@ async function executeToolCalls<T>(
|
|||
isError,
|
||||
});
|
||||
|
||||
const toolResultMessage: ToolResultMessage<T> = {
|
||||
const toolResultMessage: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
|
|
@ -315,6 +343,7 @@ async function executeToolCalls<T>(
|
|||
stream.push({ type: "message_start", message: toolResultMessage });
|
||||
stream.push({ type: "message_end", message: toolResultMessage });
|
||||
|
||||
// Check for queued messages - skip remaining tools if user interrupted
|
||||
if (getQueuedMessages) {
|
||||
const queued = await getQueuedMessages();
|
||||
if (queued.length > 0) {
|
||||
|
|
@ -331,13 +360,13 @@ async function executeToolCalls<T>(
|
|||
return { toolResults: results, queuedMessages };
|
||||
}
|
||||
|
||||
function skipToolCall<T>(
|
||||
function skipToolCall(
|
||||
toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
|
||||
stream: EventStream<AgentEvent, Message[]>,
|
||||
): ToolResultMessage<T> {
|
||||
const result: AgentToolResult<T> = {
|
||||
stream: EventStream<AgentEvent, AgentMessage[]>,
|
||||
): ToolResultMessage {
|
||||
const result: AgentToolResult<any> = {
|
||||
content: [{ type: "text", text: "Skipped due to queued user message." }],
|
||||
details: {} as T,
|
||||
details: {},
|
||||
};
|
||||
|
||||
stream.push({
|
||||
|
|
@ -354,12 +383,12 @@ function skipToolCall<T>(
|
|||
isError: true,
|
||||
});
|
||||
|
||||
const toolResultMessage: ToolResultMessage<T> = {
|
||||
const toolResultMessage: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
details: {},
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
|
@ -1,62 +1,66 @@
|
|||
import type { ImageContent, Message, QueuedMessage, ReasoningEffort, TextContent } from "@mariozechner/pi-ai";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import type { AgentTransport } from "./transports/types.js";
|
||||
import type { AgentEvent, AgentState, AppMessage, Attachment, ThinkingLevel } from "./types.js";
|
||||
/**
|
||||
* Agent class that uses the agent-loop directly.
|
||||
* No transport abstraction - calls streamSimple via the loop.
|
||||
*/
|
||||
|
||||
import {
|
||||
getModel,
|
||||
type ImageContent,
|
||||
type Message,
|
||||
type Model,
|
||||
type ReasoningEffort,
|
||||
streamSimple,
|
||||
type TextContent,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { agentLoop, agentLoopContinue } from "./agent-loop.js";
|
||||
import type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentLoopConfig,
|
||||
AgentMessage,
|
||||
AgentState,
|
||||
AgentTool,
|
||||
StreamFn,
|
||||
ThinkingLevel,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Default message transformer: Keep only LLM-compatible messages, strip app-specific fields.
|
||||
* Converts attachments to proper content blocks (images → ImageContent, documents → TextContent).
|
||||
* Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
|
||||
*/
|
||||
function defaultMessageTransformer(messages: AppMessage[]): Message[] {
|
||||
return messages
|
||||
.filter((m) => {
|
||||
// Only keep standard LLM message roles
|
||||
return m.role === "user" || m.role === "assistant" || m.role === "toolResult";
|
||||
})
|
||||
.map((m) => {
|
||||
if (m.role === "user") {
|
||||
const { attachments, ...rest } = m as any;
|
||||
|
||||
// If no attachments, return as-is
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return rest as Message;
|
||||
}
|
||||
|
||||
// Convert attachments to content blocks
|
||||
const content = Array.isArray(rest.content) ? [...rest.content] : [{ type: "text", text: rest.content }];
|
||||
|
||||
for (const attachment of attachments as Attachment[]) {
|
||||
// Add image blocks for image attachments
|
||||
if (attachment.type === "image") {
|
||||
content.push({
|
||||
type: "image",
|
||||
data: attachment.content,
|
||||
mimeType: attachment.mimeType,
|
||||
} as ImageContent);
|
||||
}
|
||||
// Add text blocks for documents with extracted text
|
||||
else if (attachment.type === "document" && attachment.extractedText) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`,
|
||||
isDocument: true,
|
||||
} as TextContent);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...rest, content } as Message;
|
||||
}
|
||||
return m as Message;
|
||||
});
|
||||
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
|
||||
}
|
||||
|
||||
export interface AgentOptions {
|
||||
initialState?: Partial<AgentState>;
|
||||
transport: AgentTransport;
|
||||
// Transform app messages to LLM-compatible messages before sending to transport
|
||||
messageTransformer?: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
|
||||
// Queue mode: "all" = send all queued messages at once, "one-at-a-time" = send one queued message per turn
|
||||
|
||||
/**
|
||||
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
|
||||
* Default filters to user/assistant/toolResult and converts attachments.
|
||||
*/
|
||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Optional transform applied to context before convertToLlm.
|
||||
* Use for context pruning, injecting external context, etc.
|
||||
*/
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
|
||||
/**
|
||||
* Queue mode: "all" = send all queued messages at once, "one-at-a-time" = one per turn
|
||||
*/
|
||||
queueMode?: "all" | "one-at-a-time";
|
||||
|
||||
/**
|
||||
* Custom stream function (for proxy backends, etc.). Default uses streamSimple.
|
||||
*/
|
||||
streamFn?: StreamFn;
|
||||
|
||||
/**
|
||||
* Resolves an API key dynamically for each LLM call.
|
||||
* Useful for expiring tokens (e.g., GitHub Copilot OAuth).
|
||||
*/
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
|
|
@ -71,20 +75,25 @@ export class Agent {
|
|||
pendingToolCalls: new Set<string>(),
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
private listeners = new Set<(e: AgentEvent) => void>();
|
||||
private abortController?: AbortController;
|
||||
private transport: AgentTransport;
|
||||
private messageTransformer: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
|
||||
private messageQueue: Array<QueuedMessage<AppMessage>> = [];
|
||||
private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
private messageQueue: AgentMessage[] = [];
|
||||
private queueMode: "all" | "one-at-a-time";
|
||||
public streamFn: StreamFn;
|
||||
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
private runningPrompt?: Promise<void>;
|
||||
private resolveRunningPrompt?: () => void;
|
||||
|
||||
constructor(opts: AgentOptions) {
|
||||
constructor(opts: AgentOptions = {}) {
|
||||
this._state = { ...this._state, ...opts.initialState };
|
||||
this.transport = opts.transport;
|
||||
this.messageTransformer = opts.messageTransformer || defaultMessageTransformer;
|
||||
this.convertToLlm = opts.convertToLlm || defaultConvertToLlm;
|
||||
this.transformContext = opts.transformContext;
|
||||
this.queueMode = opts.queueMode || "one-at-a-time";
|
||||
this.streamFn = opts.streamFn || streamSimple;
|
||||
this.getApiKey = opts.getApiKey;
|
||||
}
|
||||
|
||||
get state(): AgentState {
|
||||
|
|
@ -96,12 +105,12 @@ export class Agent {
|
|||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
// State mutators - update internal state without emitting events
|
||||
// State mutators
|
||||
setSystemPrompt(v: string) {
|
||||
this._state.systemPrompt = v;
|
||||
}
|
||||
|
||||
setModel(m: typeof this._state.model) {
|
||||
setModel(m: Model<any>) {
|
||||
this._state.model = m;
|
||||
}
|
||||
|
||||
|
|
@ -117,25 +126,20 @@ export class Agent {
|
|||
return this.queueMode;
|
||||
}
|
||||
|
||||
setTools(t: typeof this._state.tools) {
|
||||
setTools(t: AgentTool<any>[]) {
|
||||
this._state.tools = t;
|
||||
}
|
||||
|
||||
replaceMessages(ms: AppMessage[]) {
|
||||
replaceMessages(ms: AgentMessage[]) {
|
||||
this._state.messages = ms.slice();
|
||||
}
|
||||
|
||||
appendMessage(m: AppMessage) {
|
||||
appendMessage(m: AgentMessage) {
|
||||
this._state.messages = [...this._state.messages, m];
|
||||
}
|
||||
|
||||
async queueMessage(m: AppMessage) {
|
||||
// Transform message and queue it for injection at next turn
|
||||
const transformed = await this.messageTransformer([m]);
|
||||
this.messageQueue.push({
|
||||
original: m,
|
||||
llm: transformed[0], // undefined if filtered out
|
||||
});
|
||||
queueMessage(m: AgentMessage) {
|
||||
this.messageQueue.push(m);
|
||||
}
|
||||
|
||||
clearMessageQueue() {
|
||||
|
|
@ -150,17 +154,10 @@ export class Agent {
|
|||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when the current prompt completes.
|
||||
* Returns immediately resolved promise if no prompt is running.
|
||||
*/
|
||||
waitForIdle(): Promise<void> {
|
||||
return this.runningPrompt ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages and state. Call abort() first if a prompt is in flight.
|
||||
*/
|
||||
reset() {
|
||||
this._state.messages = [];
|
||||
this._state.isStreaming = false;
|
||||
|
|
@ -170,86 +167,57 @@ export class Agent {
|
|||
this.messageQueue = [];
|
||||
}
|
||||
|
||||
async prompt(input: string, attachments?: Attachment[]) {
|
||||
/** Send a prompt with an AgentMessage */
|
||||
async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
|
||||
async prompt(input: string, images?: ImageContent[]): Promise<void>;
|
||||
async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) {
|
||||
const model = this._state.model;
|
||||
if (!model) {
|
||||
throw new Error("No model configured");
|
||||
}
|
||||
if (!model) throw new Error("No model configured");
|
||||
|
||||
// Build user message with attachments
|
||||
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
|
||||
if (attachments?.length) {
|
||||
for (const a of attachments) {
|
||||
if (a.type === "image") {
|
||||
content.push({ type: "image", data: a.content, mimeType: a.mimeType });
|
||||
} else if (a.type === "document" && a.extractedText) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`,
|
||||
isDocument: true,
|
||||
} as TextContent);
|
||||
}
|
||||
let msgs: AgentMessage[];
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
msgs = input;
|
||||
} else if (typeof input === "string") {
|
||||
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
|
||||
if (images && images.length > 0) {
|
||||
content.push(...images);
|
||||
}
|
||||
msgs = [
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
msgs = [input];
|
||||
}
|
||||
|
||||
const userMessage: AppMessage = {
|
||||
role: "user",
|
||||
content,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await this._runAgentLoop(userMessage);
|
||||
await this._runLoop(msgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue from the current context without adding a new user message.
|
||||
* Used for retry after overflow recovery when context already has user message or tool results.
|
||||
*/
|
||||
/** Continue from current context (for retry after overflow) */
|
||||
async continue() {
|
||||
const messages = this._state.messages;
|
||||
if (messages.length === 0) {
|
||||
throw new Error("No messages to continue from");
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.role !== "user" && lastMessage.role !== "toolResult") {
|
||||
throw new Error(`Cannot continue from message role: ${lastMessage.role}`);
|
||||
if (messages[messages.length - 1].role === "assistant") {
|
||||
throw new Error("Cannot continue from message role: assistant");
|
||||
}
|
||||
|
||||
await this._runAgentLoopContinue();
|
||||
await this._runLoop(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Run the agent loop with a new user message.
|
||||
* Run the agent loop.
|
||||
* If messages are provided, starts a new conversation turn with those messages.
|
||||
* Otherwise, continues from existing context.
|
||||
*/
|
||||
private async _runAgentLoop(userMessage: AppMessage) {
|
||||
const { llmMessages, cfg } = await this._prepareRun();
|
||||
|
||||
const events = this.transport.run(llmMessages, userMessage as Message, cfg, this.abortController!.signal);
|
||||
|
||||
await this._processEvents(events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Continue the agent loop from current context.
|
||||
*/
|
||||
private async _runAgentLoopContinue() {
|
||||
const { llmMessages, cfg } = await this._prepareRun();
|
||||
|
||||
const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal);
|
||||
|
||||
await this._processEvents(events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare for running the agent loop.
|
||||
*/
|
||||
private async _prepareRun() {
|
||||
private async _runLoop(messages?: AgentMessage[]) {
|
||||
const model = this._state.model;
|
||||
if (!model) {
|
||||
throw new Error("No model configured");
|
||||
}
|
||||
if (!model) throw new Error("No model configured");
|
||||
|
||||
this.runningPrompt = new Promise<void>((resolve) => {
|
||||
this.resolveRunningPrompt = resolve;
|
||||
|
|
@ -265,87 +233,90 @@ export class Agent {
|
|||
? undefined
|
||||
: this._state.thinkingLevel === "minimal"
|
||||
? "low"
|
||||
: this._state.thinkingLevel;
|
||||
: (this._state.thinkingLevel as ReasoningEffort);
|
||||
|
||||
const cfg = {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: this._state.systemPrompt,
|
||||
messages: this._state.messages.slice(),
|
||||
tools: this._state.tools,
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model,
|
||||
reasoning,
|
||||
getQueuedMessages: async <T>() => {
|
||||
convertToLlm: this.convertToLlm,
|
||||
transformContext: this.transformContext,
|
||||
getApiKey: this.getApiKey,
|
||||
getQueuedMessages: async () => {
|
||||
if (this.queueMode === "one-at-a-time") {
|
||||
if (this.messageQueue.length > 0) {
|
||||
const first = this.messageQueue[0];
|
||||
this.messageQueue = this.messageQueue.slice(1);
|
||||
return [first] as QueuedMessage<T>[];
|
||||
return [first];
|
||||
}
|
||||
return [];
|
||||
} else {
|
||||
const queued = this.messageQueue.slice();
|
||||
this.messageQueue = [];
|
||||
return queued as QueuedMessage<T>[];
|
||||
return queued;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const llmMessages = await this.messageTransformer(this._state.messages);
|
||||
|
||||
return { llmMessages, cfg, model };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process events from the transport.
|
||||
*/
|
||||
private async _processEvents(events: AsyncIterable<AgentEvent>) {
|
||||
const model = this._state.model!;
|
||||
const generatedMessages: AppMessage[] = [];
|
||||
let partial: AppMessage | null = null;
|
||||
let partial: AgentMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const ev of events) {
|
||||
switch (ev.type) {
|
||||
case "message_start": {
|
||||
partial = ev.message as AppMessage;
|
||||
this._state.streamMessage = ev.message as Message;
|
||||
const stream = messages
|
||||
? agentLoop(messages, context, config, this.abortController.signal, this.streamFn)
|
||||
: agentLoopContinue(context, config, this.abortController.signal, this.streamFn);
|
||||
|
||||
for await (const event of stream) {
|
||||
// Update internal state based on events
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
partial = event.message;
|
||||
this._state.streamMessage = event.message;
|
||||
break;
|
||||
}
|
||||
case "message_update": {
|
||||
partial = ev.message as AppMessage;
|
||||
this._state.streamMessage = ev.message as Message;
|
||||
|
||||
case "message_update":
|
||||
partial = event.message;
|
||||
this._state.streamMessage = event.message;
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
|
||||
case "message_end":
|
||||
partial = null;
|
||||
this._state.streamMessage = null;
|
||||
this.appendMessage(ev.message as AppMessage);
|
||||
generatedMessages.push(ev.message as AppMessage);
|
||||
this.appendMessage(event.message);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_start": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.add(ev.toolCallId);
|
||||
s.add(event.toolCallId);
|
||||
this._state.pendingToolCalls = s;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_end": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.delete(ev.toolCallId);
|
||||
s.delete(event.toolCallId);
|
||||
this._state.pendingToolCalls = s;
|
||||
break;
|
||||
}
|
||||
case "turn_end": {
|
||||
if (ev.message.role === "assistant" && ev.message.errorMessage) {
|
||||
this._state.error = ev.message.errorMessage;
|
||||
|
||||
case "turn_end":
|
||||
if (event.message.role === "assistant" && (event.message as any).errorMessage) {
|
||||
this._state.error = (event.message as any).errorMessage;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_end": {
|
||||
|
||||
case "agent_end":
|
||||
this._state.isStreaming = false;
|
||||
this._state.streamMessage = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit(ev as AgentEvent);
|
||||
// Emit to listeners
|
||||
this.emit(event);
|
||||
}
|
||||
|
||||
// Handle any remaining partial message
|
||||
|
|
@ -357,8 +328,7 @@ export class Agent {
|
|||
(c.type === "toolCall" && c.name.trim().length > 0),
|
||||
);
|
||||
if (!onlyEmpty) {
|
||||
this.appendMessage(partial as AppMessage);
|
||||
generatedMessages.push(partial as AppMessage);
|
||||
this.appendMessage(partial);
|
||||
} else {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
|
|
@ -366,7 +336,7 @@ export class Agent {
|
|||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg: Message = {
|
||||
const errorMsg: AgentMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
|
|
@ -383,10 +353,11 @@ export class Agent {
|
|||
stopReason: this.abortController?.signal.aborted ? "aborted" : "error",
|
||||
errorMessage: err?.message || String(err),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
this.appendMessage(msg as AppMessage);
|
||||
generatedMessages.push(msg as AppMessage);
|
||||
} as AgentMessage;
|
||||
|
||||
this.appendMessage(errorMsg);
|
||||
this._state.error = err?.message || String(err);
|
||||
this.emit({ type: "agent_end", messages: [errorMsg] });
|
||||
} finally {
|
||||
this._state.isStreaming = false;
|
||||
this._state.streamMessage = null;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,8 @@
|
|||
// Core Agent
|
||||
export { Agent, type AgentOptions } from "./agent.js";
|
||||
// Transports
|
||||
export {
|
||||
type AgentRunConfig,
|
||||
type AgentTransport,
|
||||
AppTransport,
|
||||
type AppTransportOptions,
|
||||
ProviderTransport,
|
||||
type ProviderTransportOptions,
|
||||
type ProxyAssistantMessageEvent,
|
||||
} from "./transports/index.js";
|
||||
export * from "./agent.js";
|
||||
// Loop functions
|
||||
export * from "./agent-loop.js";
|
||||
// Proxy utilities
|
||||
export * from "./proxy.js";
|
||||
// Types
|
||||
export type {
|
||||
AgentEvent,
|
||||
AgentState,
|
||||
AppMessage,
|
||||
Attachment,
|
||||
CustomMessages,
|
||||
ThinkingLevel,
|
||||
UserMessageWithAttachments,
|
||||
} from "./types.js";
|
||||
export * from "./types.js";
|
||||
|
|
|
|||
340
packages/agent/src/proxy.ts
Normal file
340
packages/agent/src/proxy.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
/**
|
||||
* Proxy stream function for apps that route LLM calls through a server.
|
||||
* The server manages auth and proxies requests to LLM providers.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEvent,
|
||||
type Context,
|
||||
EventStream,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type StopReason,
|
||||
type ToolCall,
|
||||
} from "@mariozechner/pi-ai";
|
||||
// Internal import for JSON parsing utility
|
||||
import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js";
|
||||
|
||||
// Create stream class matching ProxyMessageEventStream
|
||||
class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") return event.message;
|
||||
if (event.type === "error") return event.error;
|
||||
throw new Error("Unexpected event type");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy event types - server sends these with partial field stripped to reduce bandwidth.
|
||||
*/
|
||||
export type ProxyAssistantMessageEvent =
|
||||
| { type: "start" }
|
||||
| { type: "text_start"; contentIndex: number }
|
||||
| { type: "text_delta"; contentIndex: number; delta: string }
|
||||
| { type: "text_end"; contentIndex: number; contentSignature?: string }
|
||||
| { type: "thinking_start"; contentIndex: number }
|
||||
| { type: "thinking_delta"; contentIndex: number; delta: string }
|
||||
| { type: "thinking_end"; contentIndex: number; contentSignature?: string }
|
||||
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
|
||||
| { type: "toolcall_delta"; contentIndex: number; delta: string }
|
||||
| { type: "toolcall_end"; contentIndex: number }
|
||||
| {
|
||||
type: "done";
|
||||
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
|
||||
usage: AssistantMessage["usage"];
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
reason: Extract<StopReason, "aborted" | "error">;
|
||||
errorMessage?: string;
|
||||
usage: AssistantMessage["usage"];
|
||||
};
|
||||
|
||||
export interface ProxyStreamOptions extends SimpleStreamOptions {
|
||||
/** Auth token for the proxy server */
|
||||
authToken: string;
|
||||
/** Proxy server URL (e.g., "https://genai.example.com") */
|
||||
proxyUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream function that proxies through a server instead of calling LLM providers directly.
|
||||
* The server strips the partial field from delta events to reduce bandwidth.
|
||||
* We reconstruct the partial message client-side.
|
||||
*
|
||||
* Use this as the `streamFn` option when creating an Agent that needs to go through a proxy.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const agent = new Agent({
|
||||
* streamFn: (model, context, options) =>
|
||||
* streamProxy(model, context, {
|
||||
* ...options,
|
||||
* authToken: await getAuthToken(),
|
||||
* proxyUrl: "https://genai.example.com",
|
||||
* }),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function streamProxy(model: Model<any>, context: Context, options: ProxyStreamOptions): ProxyMessageEventStream {
|
||||
const stream = new ProxyMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
// Initialize the partial message that we'll build up from events
|
||||
const partial: AssistantMessage = {
|
||||
role: "assistant",
|
||||
stopReason: "stop",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
|
||||
const abortHandler = () => {
|
||||
if (reader) {
|
||||
reader.cancel("Request aborted by user").catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener("abort", abortHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${options.proxyUrl}/api/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
context,
|
||||
options: {
|
||||
temperature: options.temperature,
|
||||
maxTokens: options.maxTokens,
|
||||
reasoning: options.reasoning,
|
||||
},
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Proxy error: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
if (errorData.error) {
|
||||
errorMessage = `Proxy error: ${errorData.error}`;
|
||||
}
|
||||
} catch {
|
||||
// Couldn't parse error response
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data) {
|
||||
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
|
||||
const event = processProxyEvent(proxyEvent, partial);
|
||||
if (event) {
|
||||
stream.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const reason = options.signal?.aborted ? "aborted" : "error";
|
||||
partial.stopReason = reason;
|
||||
partial.errorMessage = errorMessage;
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason,
|
||||
error: partial,
|
||||
});
|
||||
stream.end();
|
||||
} finally {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a proxy event and update the partial message.
|
||||
*/
|
||||
function processProxyEvent(
|
||||
proxyEvent: ProxyAssistantMessageEvent,
|
||||
partial: AssistantMessage,
|
||||
): AssistantMessageEvent | undefined {
|
||||
switch (proxyEvent.type) {
|
||||
case "start":
|
||||
return { type: "start", partial };
|
||||
|
||||
case "text_start":
|
||||
partial.content[proxyEvent.contentIndex] = { type: "text", text: "" };
|
||||
return { type: "text_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
|
||||
case "text_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "text") {
|
||||
content.text += proxyEvent.delta;
|
||||
return {
|
||||
type: "text_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
throw new Error("Received text_delta for non-text content");
|
||||
}
|
||||
|
||||
case "text_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "text") {
|
||||
content.textSignature = proxyEvent.contentSignature;
|
||||
return {
|
||||
type: "text_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
content: content.text,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
throw new Error("Received text_end for non-text content");
|
||||
}
|
||||
|
||||
case "thinking_start":
|
||||
partial.content[proxyEvent.contentIndex] = { type: "thinking", thinking: "" };
|
||||
return { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
|
||||
case "thinking_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "thinking") {
|
||||
content.thinking += proxyEvent.delta;
|
||||
return {
|
||||
type: "thinking_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
throw new Error("Received thinking_delta for non-thinking content");
|
||||
}
|
||||
|
||||
case "thinking_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "thinking") {
|
||||
content.thinkingSignature = proxyEvent.contentSignature;
|
||||
return {
|
||||
type: "thinking_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
content: content.thinking,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
throw new Error("Received thinking_end for non-thinking content");
|
||||
}
|
||||
|
||||
case "toolcall_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "toolCall",
|
||||
id: proxyEvent.id,
|
||||
name: proxyEvent.toolName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
} satisfies ToolCall & { partialJson: string } as ToolCall;
|
||||
return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
|
||||
case "toolcall_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
(content as any).partialJson += proxyEvent.delta;
|
||||
content.arguments = parseStreamingJson((content as any).partialJson) || {};
|
||||
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
|
||||
return {
|
||||
type: "toolcall_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
throw new Error("Received toolcall_delta for non-toolCall content");
|
||||
}
|
||||
|
||||
case "toolcall_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
delete (content as any).partialJson;
|
||||
return {
|
||||
type: "toolcall_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
toolCall: content,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case "done":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.usage = proxyEvent.usage;
|
||||
return { type: "done", reason: proxyEvent.reason, message: partial };
|
||||
|
||||
case "error":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.errorMessage = proxyEvent.errorMessage;
|
||||
partial.usage = proxyEvent.usage;
|
||||
return { type: "error", reason: proxyEvent.reason, error: partial };
|
||||
|
||||
default: {
|
||||
const _exhaustiveCheck: never = proxyEvent;
|
||||
console.warn(`Unhandled proxy event type: ${(proxyEvent as any).type}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
import type {
|
||||
AgentContext,
|
||||
AgentLoopConfig,
|
||||
Api,
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
Context,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { agentLoop, agentLoopContinue } from "@mariozechner/pi-ai";
|
||||
import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js";
|
||||
import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js";
|
||||
import type { ProxyAssistantMessageEvent } from "./proxy-types.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||
|
||||
/**
|
||||
* Stream function that proxies through a server instead of calling providers directly.
|
||||
* The server strips the partial field from delta events to reduce bandwidth.
|
||||
* We reconstruct the partial message client-side.
|
||||
*/
|
||||
function streamSimpleProxy(
|
||||
model: Model<any>,
|
||||
context: Context,
|
||||
options: SimpleStreamOptions & { authToken: string },
|
||||
proxyUrl: string,
|
||||
): AssistantMessageEventStream {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
// Initialize the partial message that we'll build up from events
|
||||
const partial: AssistantMessage = {
|
||||
role: "assistant",
|
||||
stopReason: "stop",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
|
||||
// Set up abort handler to cancel the reader
|
||||
const abortHandler = () => {
|
||||
if (reader) {
|
||||
reader.cancel("Request aborted by user").catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener("abort", abortHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${proxyUrl}/api/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
context,
|
||||
options: {
|
||||
temperature: options.temperature,
|
||||
maxTokens: options.maxTokens,
|
||||
reasoning: options.reasoning,
|
||||
// Don't send apiKey or signal - those are added server-side
|
||||
},
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Proxy error: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
if (errorData.error) {
|
||||
errorMessage = `Proxy error: ${errorData.error}`;
|
||||
}
|
||||
} catch {
|
||||
// Couldn't parse error response, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Check if aborted after reading
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data) {
|
||||
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
|
||||
let event: AssistantMessageEvent | undefined;
|
||||
|
||||
// Handle different event types
|
||||
// Server sends events with partial for non-delta events,
|
||||
// and without partial for delta events
|
||||
switch (proxyEvent.type) {
|
||||
case "start":
|
||||
event = { type: "start", partial };
|
||||
break;
|
||||
|
||||
case "text_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "text",
|
||||
text: "",
|
||||
};
|
||||
event = { type: "text_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "text_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "text") {
|
||||
content.text += proxyEvent.delta;
|
||||
event = {
|
||||
type: "text_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received text_delta for non-text content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "text_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "text") {
|
||||
content.textSignature = proxyEvent.contentSignature;
|
||||
event = {
|
||||
type: "text_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
content: content.text,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received text_end for non-text content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "thinking_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
};
|
||||
event = { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "thinking_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "thinking") {
|
||||
content.thinking += proxyEvent.delta;
|
||||
event = {
|
||||
type: "thinking_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received thinking_delta for non-thinking content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "thinking_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "thinking") {
|
||||
content.thinkingSignature = proxyEvent.contentSignature;
|
||||
event = {
|
||||
type: "thinking_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
content: content.thinking,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received thinking_end for non-thinking content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "toolcall_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "toolCall",
|
||||
id: proxyEvent.id,
|
||||
name: proxyEvent.toolName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
} satisfies ToolCall & { partialJson: string } as ToolCall;
|
||||
event = { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "toolcall_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
(content as any).partialJson += proxyEvent.delta;
|
||||
content.arguments = parseStreamingJson((content as any).partialJson) || {};
|
||||
event = {
|
||||
type: "toolcall_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
|
||||
} else {
|
||||
throw new Error("Received toolcall_delta for non-toolCall content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "toolcall_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
delete (content as any).partialJson;
|
||||
event = {
|
||||
type: "toolcall_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
toolCall: content,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "done":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.usage = proxyEvent.usage;
|
||||
event = { type: "done", reason: proxyEvent.reason, message: partial };
|
||||
break;
|
||||
|
||||
case "error":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.errorMessage = proxyEvent.errorMessage;
|
||||
partial.usage = proxyEvent.usage;
|
||||
event = { type: "error", reason: proxyEvent.reason, error: partial };
|
||||
break;
|
||||
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustiveCheck: never = proxyEvent;
|
||||
console.warn(`Unhandled event type: ${(proxyEvent as any).type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Push the event to stream
|
||||
if (event) {
|
||||
stream.push(event);
|
||||
} else {
|
||||
throw new Error("Failed to create event from proxy event");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if aborted after reading
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
partial.stopReason = options.signal?.aborted ? "aborted" : "error";
|
||||
partial.errorMessage = errorMessage;
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: partial.stopReason,
|
||||
error: partial,
|
||||
} satisfies AssistantMessageEvent);
|
||||
stream.end();
|
||||
} finally {
|
||||
// Clean up abort handler
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
export interface AppTransportOptions {
|
||||
/**
|
||||
* Proxy server URL. The server manages user accounts and proxies requests to LLM providers.
|
||||
* Example: "https://genai.mariozechner.at"
|
||||
*/
|
||||
proxyUrl: string;
|
||||
|
||||
/**
|
||||
* Function to retrieve auth token for the proxy server.
|
||||
* The token is used for user authentication and authorization.
|
||||
*/
|
||||
getAuthToken: () => Promise<string> | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport that uses an app server with user authentication tokens.
|
||||
* The server manages user accounts and proxies requests to LLM providers.
|
||||
*/
|
||||
export class AppTransport implements AgentTransport {
|
||||
private options: AppTransportOptions;
|
||||
|
||||
constructor(options: AppTransportOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private async getStreamFn(authToken: string) {
|
||||
return <TApi extends Api>(model: Model<TApi>, context: Context, options?: SimpleStreamOptions) => {
|
||||
return streamSimpleProxy(
|
||||
model,
|
||||
context,
|
||||
{
|
||||
...options,
|
||||
authToken,
|
||||
},
|
||||
this.options.proxyUrl,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext {
|
||||
return {
|
||||
systemPrompt: cfg.systemPrompt,
|
||||
messages,
|
||||
tools: cfg.tools,
|
||||
};
|
||||
}
|
||||
|
||||
private buildLoopConfig(cfg: AgentRunConfig): AgentLoopConfig {
|
||||
return {
|
||||
model: cfg.model,
|
||||
reasoning: cfg.reasoning,
|
||||
getQueuedMessages: cfg.getQueuedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
const authToken = await this.options.getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error("Auth token is required for AppTransport");
|
||||
}
|
||||
|
||||
const streamFn = await this.getStreamFn(authToken);
|
||||
const context = this.buildContext(messages, cfg);
|
||||
const pc = this.buildLoopConfig(cfg);
|
||||
|
||||
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn as any)) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
|
||||
async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
const authToken = await this.options.getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error("Auth token is required for AppTransport");
|
||||
}
|
||||
|
||||
const streamFn = await this.getStreamFn(authToken);
|
||||
const context = this.buildContext(messages, cfg);
|
||||
const pc = this.buildLoopConfig(cfg);
|
||||
|
||||
for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any)) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import {
|
||||
type AgentContext,
|
||||
type AgentLoopConfig,
|
||||
agentLoop,
|
||||
agentLoopContinue,
|
||||
type Message,
|
||||
type UserMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||
|
||||
export interface ProviderTransportOptions {
|
||||
/**
|
||||
* Function to retrieve API key for a given provider.
|
||||
* If not provided, transport will try to use environment variables.
|
||||
*/
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
|
||||
/**
|
||||
* Optional CORS proxy URL for browser environments.
|
||||
* If provided, all requests will be routed through this proxy.
|
||||
* Format: "https://proxy.example.com"
|
||||
*/
|
||||
corsProxyUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport that calls LLM providers directly.
|
||||
* Optionally routes calls through a CORS proxy if configured.
|
||||
*/
|
||||
export class ProviderTransport implements AgentTransport {
|
||||
private options: ProviderTransportOptions;
|
||||
|
||||
constructor(options: ProviderTransportOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private getModel(cfg: AgentRunConfig) {
|
||||
let model = cfg.model;
|
||||
if (this.options.corsProxyUrl && cfg.model.baseUrl) {
|
||||
model = {
|
||||
...cfg.model,
|
||||
baseUrl: `${this.options.corsProxyUrl}/?url=${encodeURIComponent(cfg.model.baseUrl)}`,
|
||||
};
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
private buildContext(messages: Message[], cfg: AgentRunConfig): AgentContext {
|
||||
return {
|
||||
systemPrompt: cfg.systemPrompt,
|
||||
messages,
|
||||
tools: cfg.tools,
|
||||
};
|
||||
}
|
||||
|
||||
private buildLoopConfig(model: AgentRunConfig["model"], cfg: AgentRunConfig): AgentLoopConfig {
|
||||
return {
|
||||
model,
|
||||
reasoning: cfg.reasoning,
|
||||
// Resolve API key per assistant response (important for expiring OAuth tokens)
|
||||
getApiKey: this.options.getApiKey,
|
||||
getQueuedMessages: cfg.getQueuedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
const model = this.getModel(cfg);
|
||||
const context = this.buildContext(messages, cfg);
|
||||
const pc = this.buildLoopConfig(model, cfg);
|
||||
|
||||
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
|
||||
async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
const model = this.getModel(cfg);
|
||||
const context = this.buildContext(messages, cfg);
|
||||
const pc = this.buildLoopConfig(model, cfg);
|
||||
|
||||
for await (const ev of agentLoopContinue(context, pc, signal)) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export { AppTransport, type AppTransportOptions } from "./AppTransport.js";
|
||||
export { ProviderTransport, type ProviderTransportOptions } from "./ProviderTransport.js";
|
||||
export type { ProxyAssistantMessageEvent } from "./proxy-types.js";
|
||||
export type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import type { StopReason, Usage } from "@mariozechner/pi-ai";
|
||||
|
||||
/**
|
||||
* Event types emitted by the proxy server.
|
||||
* The server strips the `partial` field from delta events to reduce bandwidth.
|
||||
* Clients reconstruct the partial message from these events.
|
||||
*/
|
||||
export type ProxyAssistantMessageEvent =
|
||||
| { type: "start" }
|
||||
| { type: "text_start"; contentIndex: number }
|
||||
| { type: "text_delta"; contentIndex: number; delta: string }
|
||||
| { type: "text_end"; contentIndex: number; contentSignature?: string }
|
||||
| { type: "thinking_start"; contentIndex: number }
|
||||
| { type: "thinking_delta"; contentIndex: number; delta: string }
|
||||
| { type: "thinking_end"; contentIndex: number; contentSignature?: string }
|
||||
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
|
||||
| { type: "toolcall_delta"; contentIndex: number; delta: string }
|
||||
| { type: "toolcall_end"; contentIndex: number }
|
||||
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; usage: Usage }
|
||||
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; errorMessage: string; usage: Usage };
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import type { AgentEvent, AgentTool, Message, Model, QueuedMessage, ReasoningEffort } from "@mariozechner/pi-ai";
|
||||
|
||||
/**
|
||||
* The minimal configuration needed to run an agent turn.
|
||||
*/
|
||||
export interface AgentRunConfig {
|
||||
systemPrompt: string;
|
||||
tools: AgentTool<any>[];
|
||||
model: Model<any>;
|
||||
reasoning?: ReasoningEffort;
|
||||
getQueuedMessages?: <T>() => Promise<QueuedMessage<T>[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport interface for executing agent turns.
|
||||
* Transports handle the communication with LLM providers,
|
||||
* abstracting away the details of API calls, proxies, etc.
|
||||
*
|
||||
* Events yielded must match the @mariozechner/pi-ai AgentEvent types.
|
||||
*/
|
||||
export interface AgentTransport {
|
||||
/** Run with a new user message */
|
||||
run(
|
||||
messages: Message[],
|
||||
userMessage: Message,
|
||||
config: AgentRunConfig,
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<AgentEvent>;
|
||||
|
||||
/** Continue from current context (no new user message) */
|
||||
continue(messages: Message[], config: AgentRunConfig, signal?: AbortSignal): AsyncIterable<AgentEvent>;
|
||||
}
|
||||
|
|
@ -1,26 +1,86 @@
|
|||
import type {
|
||||
AgentTool,
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
streamSimple,
|
||||
TextContent,
|
||||
Tool,
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
|
||||
/** Stream function - can return sync or Promise for async config lookup */
|
||||
export type StreamFn = (
|
||||
...args: Parameters<typeof streamSimple>
|
||||
) => ReturnType<typeof streamSimple> | Promise<ReturnType<typeof streamSimple>>;
|
||||
|
||||
/**
|
||||
* Attachment type definition.
|
||||
* Processing is done by consumers (e.g., document extraction in web-ui).
|
||||
* Configuration for the agent loop.
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
type: "image" | "document";
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
content: string; // base64 encoded (without data URL prefix)
|
||||
extractedText?: string; // For documents
|
||||
preview?: string; // base64 image preview
|
||||
export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
model: Model<any>;
|
||||
|
||||
/**
|
||||
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
|
||||
*
|
||||
* Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage
|
||||
* that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,
|
||||
* status messages) should be filtered out.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* convertToLlm: (messages) => messages.flatMap(m => {
|
||||
* if (m.role === "hookMessage") {
|
||||
* // Convert custom message to user message
|
||||
* return [{ role: "user", content: m.content, timestamp: m.timestamp }];
|
||||
* }
|
||||
* if (m.role === "notification") {
|
||||
* // Filter out UI-only messages
|
||||
* return [];
|
||||
* }
|
||||
* // Pass through standard LLM messages
|
||||
* return [m];
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Optional transform applied to the context before `convertToLlm`.
|
||||
*
|
||||
* Use this for operations that work at the AgentMessage level:
|
||||
* - Context window management (pruning old messages)
|
||||
* - Injecting context from external sources
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* transformContext: async (messages) => {
|
||||
* if (estimateTokens(messages) > MAX_TOKENS) {
|
||||
* return pruneOldMessages(messages);
|
||||
* }
|
||||
* return messages;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
|
||||
/**
|
||||
* Resolves an API key dynamically for each LLM call.
|
||||
*
|
||||
* Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire
|
||||
* during long-running tool execution phases.
|
||||
*/
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
|
||||
/**
|
||||
* Returns queued messages to inject into the conversation.
|
||||
*
|
||||
* Called after each turn to check for user interruptions or injected messages.
|
||||
* If messages are returned, they're added to the context before the next LLM call.
|
||||
*/
|
||||
getQueuedMessages?: () => Promise<AgentMessage[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -29,11 +89,6 @@ export interface Attachment {
|
|||
*/
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
/**
|
||||
* User message with optional attachments.
|
||||
*/
|
||||
export type UserMessageWithAttachments = UserMessage & { attachments?: Attachment[] };
|
||||
|
||||
/**
|
||||
* Extensible interface for custom app messages.
|
||||
* Apps can extend via declaration merging:
|
||||
|
|
@ -41,27 +96,23 @@ export type UserMessageWithAttachments = UserMessage & { attachments?: Attachmen
|
|||
* @example
|
||||
* ```typescript
|
||||
* declare module "@mariozechner/agent" {
|
||||
* interface CustomMessages {
|
||||
* interface CustomAgentMessages {
|
||||
* artifact: ArtifactMessage;
|
||||
* notification: NotificationMessage;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface CustomMessages {
|
||||
export interface CustomAgentMessages {
|
||||
// Empty by default - apps extend via declaration merging
|
||||
}
|
||||
|
||||
/**
|
||||
* AppMessage: Union of LLM messages + attachments + custom messages.
|
||||
* AgentMessage: Union of LLM messages + custom messages.
|
||||
* This abstraction allows apps to add custom message types while maintaining
|
||||
* type safety and compatibility with the base LLM messages.
|
||||
*/
|
||||
export type AppMessage =
|
||||
| AssistantMessage
|
||||
| UserMessageWithAttachments
|
||||
| Message // Includes ToolResultMessage
|
||||
| CustomMessages[keyof CustomMessages];
|
||||
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
|
||||
|
||||
/**
|
||||
* Agent state containing all configuration and conversation data.
|
||||
|
|
@ -71,13 +122,42 @@ export interface AgentState {
|
|||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: AgentTool<any>[];
|
||||
messages: AppMessage[]; // Can include attachments + custom message types
|
||||
messages: AgentMessage[]; // Can include attachments + custom message types
|
||||
isStreaming: boolean;
|
||||
streamMessage: Message | null;
|
||||
streamMessage: AgentMessage | null;
|
||||
pendingToolCalls: Set<string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentToolResult<T> {
|
||||
// Content blocks supporting text and images
|
||||
content: (TextContent | ImageContent)[];
|
||||
// Details to be displayed in a UI or logged
|
||||
details: T;
|
||||
}
|
||||
|
||||
// Callback for streaming tool execution updates
|
||||
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
||||
|
||||
// AgentTool extends Tool but adds the execute function
|
||||
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
|
||||
// A human-readable label for the tool to be displayed in UI
|
||||
label: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>,
|
||||
) => Promise<AgentToolResult<TDetails>>;
|
||||
}
|
||||
|
||||
// AgentContext is like Context but uses AgentTool
|
||||
export interface AgentContext {
|
||||
systemPrompt: string;
|
||||
messages: AgentMessage[];
|
||||
tools?: AgentTool<any>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the Agent for UI updates.
|
||||
* These events provide fine-grained lifecycle information for messages, turns, and tool executions.
|
||||
|
|
@ -85,15 +165,15 @@ export interface AgentState {
|
|||
export type AgentEvent =
|
||||
// Agent lifecycle
|
||||
| { type: "agent_start" }
|
||||
| { type: "agent_end"; messages: AppMessage[] }
|
||||
| { type: "agent_end"; messages: AgentMessage[] }
|
||||
// Turn lifecycle - a turn is one assistant response + any tool calls/results
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: AppMessage; toolResults: ToolResultMessage[] }
|
||||
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
|
||||
// Message lifecycle - emitted for user, assistant, and toolResult messages
|
||||
| { type: "message_start"; message: AppMessage }
|
||||
| { type: "message_start"; message: AgentMessage }
|
||||
// Only emitted for assistant messages during streaming
|
||||
| { type: "message_update"; message: AppMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||
| { type: "message_end"; message: AppMessage }
|
||||
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||
| { type: "message_end"; message: AgentMessage }
|
||||
// Tool execution lifecycle
|
||||
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
|
||||
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
|
||||
|
|
|
|||
535
packages/agent/test/agent-loop.test.ts
Normal file
535
packages/agent/test/agent-loop.test.ts
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEvent,
|
||||
EventStream,
|
||||
type Message,
|
||||
type Model,
|
||||
type UserMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentLoop, agentLoopContinue } from "../src/agent-loop.js";
|
||||
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.js";
|
||||
|
||||
// Mock stream for testing - mimics MockAssistantStream
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
constructor() {
|
||||
super(
|
||||
(event) => event.type === "done" || event.type === "error",
|
||||
(event) => {
|
||||
if (event.type === "done") return event.message;
|
||||
if (event.type === "error") return event.error;
|
||||
throw new Error("Unexpected event type");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createUsage() {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(): Model<"openai-responses"> {
|
||||
return {
|
||||
id: "mock",
|
||||
name: "mock",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantMessage(
|
||||
content: AssistantMessage["content"],
|
||||
stopReason: AssistantMessage["stopReason"] = "stop",
|
||||
): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "mock",
|
||||
usage: createUsage(),
|
||||
stopReason,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(text: string): UserMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// Simple identity converter for tests - just passes through standard messages
|
||||
function identityConverter(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
|
||||
}
|
||||
|
||||
describe("agentLoop with AgentMessage", () => {
|
||||
it("should emit events with AgentMessage types", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const userPrompt: AgentMessage = createUserMessage("Hello");
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
};
|
||||
|
||||
const streamFn = () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage([{ type: "text", text: "Hi there!" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const messages = await stream.result();
|
||||
|
||||
// Should have user message and assistant message
|
||||
expect(messages.length).toBe(2);
|
||||
expect(messages[0].role).toBe("user");
|
||||
expect(messages[1].role).toBe("assistant");
|
||||
|
||||
// Verify event sequence
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
expect(eventTypes).toContain("agent_start");
|
||||
expect(eventTypes).toContain("turn_start");
|
||||
expect(eventTypes).toContain("message_start");
|
||||
expect(eventTypes).toContain("message_end");
|
||||
expect(eventTypes).toContain("turn_end");
|
||||
expect(eventTypes).toContain("agent_end");
|
||||
});
|
||||
|
||||
it("should handle custom message types via convertToLlm", async () => {
|
||||
// Create a custom message type
|
||||
interface CustomNotification {
|
||||
role: "notification";
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const notification: CustomNotification = {
|
||||
role: "notification",
|
||||
text: "This is a notification",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [notification as unknown as AgentMessage], // Custom message in context
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const userPrompt: AgentMessage = createUserMessage("Hello");
|
||||
|
||||
let convertedMessages: Message[] = [];
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: (messages) => {
|
||||
// Filter out notifications, convert rest
|
||||
convertedMessages = messages
|
||||
.filter((m) => (m as { role: string }).role !== "notification")
|
||||
.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
|
||||
return convertedMessages;
|
||||
},
|
||||
};
|
||||
|
||||
const streamFn = () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage([{ type: "text", text: "Response" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// The notification should have been filtered out in convertToLlm
|
||||
expect(convertedMessages.length).toBe(1); // Only user message
|
||||
expect(convertedMessages[0].role).toBe("user");
|
||||
});
|
||||
|
||||
it("should apply transformContext before convertToLlm", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [
|
||||
createUserMessage("old message 1"),
|
||||
createAssistantMessage([{ type: "text", text: "old response 1" }]),
|
||||
createUserMessage("old message 2"),
|
||||
createAssistantMessage([{ type: "text", text: "old response 2" }]),
|
||||
],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const userPrompt: AgentMessage = createUserMessage("new message");
|
||||
|
||||
let transformedMessages: AgentMessage[] = [];
|
||||
let convertedMessages: Message[] = [];
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
transformContext: async (messages) => {
|
||||
// Keep only last 2 messages (prune old ones)
|
||||
transformedMessages = messages.slice(-2);
|
||||
return transformedMessages;
|
||||
},
|
||||
convertToLlm: (messages) => {
|
||||
convertedMessages = messages.filter(
|
||||
(m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult",
|
||||
) as Message[];
|
||||
return convertedMessages;
|
||||
},
|
||||
};
|
||||
|
||||
const streamFn = () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage([{ type: "text", text: "Response" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
|
||||
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
}
|
||||
|
||||
// transformContext should have been called first, keeping only last 2
|
||||
expect(transformedMessages.length).toBe(2);
|
||||
// Then convertToLlm receives the pruned messages
|
||||
expect(convertedMessages.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle tool calls and results", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
executed.push(params.value);
|
||||
return {
|
||||
content: [{ type: "text", text: `echoed: ${params.value}` }],
|
||||
details: { value: params.value },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
||||
const userPrompt: AgentMessage = createUserMessage("echo something");
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
};
|
||||
|
||||
let callIndex = 0;
|
||||
const streamFn = () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callIndex === 0) {
|
||||
// First call: return tool call
|
||||
const message = createAssistantMessage(
|
||||
[{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
|
||||
"toolUse",
|
||||
);
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
} else {
|
||||
// Second call: return final response
|
||||
const message = createAssistantMessage([{ type: "text", text: "done" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
}
|
||||
callIndex++;
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Tool should have been executed
|
||||
expect(executed).toEqual(["hello"]);
|
||||
|
||||
// Should have tool execution events
|
||||
const toolStart = events.find((e) => e.type === "tool_execution_start");
|
||||
const toolEnd = events.find((e) => e.type === "tool_execution_end");
|
||||
expect(toolStart).toBeDefined();
|
||||
expect(toolEnd).toBeDefined();
|
||||
if (toolEnd?.type === "tool_execution_end") {
|
||||
expect(toolEnd.isError).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should inject queued messages and skip remaining tool calls", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
executed.push(params.value);
|
||||
return {
|
||||
content: [{ type: "text", text: `ok:${params.value}` }],
|
||||
details: { value: params.value },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
||||
const userPrompt: AgentMessage = createUserMessage("start");
|
||||
const queuedUserMessage: AgentMessage = createUserMessage("interrupt");
|
||||
|
||||
let queuedDelivered = false;
|
||||
let callIndex = 0;
|
||||
let sawInterruptInContext = false;
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
getQueuedMessages: async () => {
|
||||
// Return queued message after first tool executes
|
||||
if (executed.length === 1 && !queuedDelivered) {
|
||||
queuedDelivered = true;
|
||||
return [queuedUserMessage];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop([userPrompt], context, config, undefined, (_model, ctx, _options) => {
|
||||
// Check if interrupt message is in context on second call
|
||||
if (callIndex === 1) {
|
||||
sawInterruptInContext = ctx.messages.some(
|
||||
(m) => m.role === "user" && typeof m.content === "string" && m.content === "interrupt",
|
||||
);
|
||||
}
|
||||
|
||||
const mockStream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (callIndex === 0) {
|
||||
// First call: return two tool calls
|
||||
const message = createAssistantMessage(
|
||||
[
|
||||
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
|
||||
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
|
||||
],
|
||||
"toolUse",
|
||||
);
|
||||
mockStream.push({ type: "done", reason: "toolUse", message });
|
||||
} else {
|
||||
// Second call: return final response
|
||||
const message = createAssistantMessage([{ type: "text", text: "done" }]);
|
||||
mockStream.push({ type: "done", reason: "stop", message });
|
||||
}
|
||||
callIndex++;
|
||||
});
|
||||
return mockStream;
|
||||
});
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Only first tool should have executed
|
||||
expect(executed).toEqual(["first"]);
|
||||
|
||||
// Second tool should be skipped
|
||||
const toolEnds = events.filter(
|
||||
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
|
||||
);
|
||||
expect(toolEnds.length).toBe(2);
|
||||
expect(toolEnds[0].isError).toBe(false);
|
||||
expect(toolEnds[1].isError).toBe(true);
|
||||
if (toolEnds[1].result.content[0]?.type === "text") {
|
||||
expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message");
|
||||
}
|
||||
|
||||
// Queued message should appear in events
|
||||
const queuedMessageEvent = events.find(
|
||||
(e) =>
|
||||
e.type === "message_start" &&
|
||||
e.message.role === "user" &&
|
||||
typeof e.message.content === "string" &&
|
||||
e.message.content === "interrupt",
|
||||
);
|
||||
expect(queuedMessageEvent).toBeDefined();
|
||||
|
||||
// Interrupt message should be in context when second LLM call is made
|
||||
expect(sawInterruptInContext).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentLoopContinue with AgentMessage", () => {
|
||||
it("should throw when context has no messages", () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
};
|
||||
|
||||
expect(() => agentLoopContinue(context, config)).toThrow("Cannot continue: no messages in context");
|
||||
});
|
||||
|
||||
it("should continue from existing context without emitting user message events", async () => {
|
||||
const userMessage: AgentMessage = createUserMessage("Hello");
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [userMessage],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
};
|
||||
|
||||
const streamFn = () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage([{ type: "text", text: "Response" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoopContinue(context, config, undefined, streamFn);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const messages = await stream.result();
|
||||
|
||||
// Should only return the new assistant message (not the existing user message)
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0].role).toBe("assistant");
|
||||
|
||||
// Should NOT have user message events (that's the key difference from agentLoop)
|
||||
const messageEndEvents = events.filter((e) => e.type === "message_end");
|
||||
expect(messageEndEvents.length).toBe(1);
|
||||
expect((messageEndEvents[0] as any).message.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("should allow custom message types as last message (caller responsibility)", async () => {
|
||||
// Custom message that will be converted to user message by convertToLlm
|
||||
interface HookMessage {
|
||||
role: "hookMessage";
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const hookMessage: HookMessage = {
|
||||
role: "hookMessage",
|
||||
text: "Hook content",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [hookMessage as unknown as AgentMessage],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: (messages) => {
|
||||
// Convert hookMessage to user message
|
||||
return messages
|
||||
.map((m) => {
|
||||
if ((m as any).role === "hookMessage") {
|
||||
return {
|
||||
role: "user" as const,
|
||||
content: (m as any).text,
|
||||
timestamp: m.timestamp,
|
||||
};
|
||||
}
|
||||
return m;
|
||||
})
|
||||
.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
|
||||
},
|
||||
};
|
||||
|
||||
const streamFn = () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage([{ type: "text", text: "Response to hook" }]);
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
// Should not throw - the hookMessage will be converted to user message
|
||||
const stream = agentLoopContinue(context, config, undefined, streamFn);
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const messages = await stream.result();
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0].role).toBe("assistant");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Agent, ProviderTransport } from "../src/index.js";
|
||||
import { Agent } from "../src/index.js";
|
||||
|
||||
describe("Agent", () => {
|
||||
it("should create an agent instance with default state", () => {
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
});
|
||||
const agent = new Agent();
|
||||
|
||||
expect(agent.state).toBeDefined();
|
||||
expect(agent.state.systemPrompt).toBe("");
|
||||
|
|
@ -23,7 +21,6 @@ describe("Agent", () => {
|
|||
it("should create an agent instance with custom initial state", () => {
|
||||
const customModel = getModel("openai", "gpt-4o-mini");
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model: customModel,
|
||||
|
|
@ -37,9 +34,7 @@ describe("Agent", () => {
|
|||
});
|
||||
|
||||
it("should subscribe to events", () => {
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
});
|
||||
const agent = new Agent();
|
||||
|
||||
let eventCount = 0;
|
||||
const unsubscribe = agent.subscribe((_event) => {
|
||||
|
|
@ -61,9 +56,7 @@ describe("Agent", () => {
|
|||
});
|
||||
|
||||
it("should update state with mutators", () => {
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
});
|
||||
const agent = new Agent();
|
||||
|
||||
// Test setSystemPrompt
|
||||
agent.setSystemPrompt("Custom prompt");
|
||||
|
|
@ -101,38 +94,19 @@ describe("Agent", () => {
|
|||
});
|
||||
|
||||
it("should support message queueing", async () => {
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
});
|
||||
const agent = new Agent();
|
||||
|
||||
const message = { role: "user" as const, content: "Queued message", timestamp: Date.now() };
|
||||
await agent.queueMessage(message);
|
||||
agent.queueMessage(message);
|
||||
|
||||
// The message is queued but not yet in state.messages
|
||||
expect(agent.state.messages).not.toContainEqual(message);
|
||||
});
|
||||
|
||||
it("should handle abort controller", () => {
|
||||
const agent = new Agent({
|
||||
transport: new ProviderTransport(),
|
||||
});
|
||||
const agent = new Agent();
|
||||
|
||||
// Should not throw even if nothing is running
|
||||
expect(() => agent.abort()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderTransport", () => {
|
||||
it("should create a provider transport instance", () => {
|
||||
const transport = new ProviderTransport();
|
||||
expect(transport).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a provider transport with options", () => {
|
||||
const transport = new ProviderTransport({
|
||||
getApiKey: async (provider) => `test-key-${provider}`,
|
||||
corsProxyUrl: "https://proxy.example.com",
|
||||
});
|
||||
expect(transport).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,8 @@
|
|||
import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
||||
import { calculateTool, getModel } from "@mariozechner/pi-ai";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Agent, ProviderTransport } from "../src/index.js";
|
||||
|
||||
function createTransport() {
|
||||
return new ProviderTransport({
|
||||
getApiKey: async (provider) => {
|
||||
const envVarMap: Record<string, string> = {
|
||||
google: "GEMINI_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
};
|
||||
const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`;
|
||||
return process.env[envVar];
|
||||
},
|
||||
});
|
||||
}
|
||||
import { Agent } from "../src/index.js";
|
||||
import { calculateTool } from "./utils/calculate.js";
|
||||
|
||||
async function basicPrompt(model: Model<any>) {
|
||||
const agent = new Agent({
|
||||
|
|
@ -29,7 +12,6 @@ async function basicPrompt(model: Model<any>) {
|
|||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
await agent.prompt("What is 2+2? Answer with just the number.");
|
||||
|
|
@ -57,7 +39,6 @@ async function toolExecution(model: Model<any>) {
|
|||
thinkingLevel: "off",
|
||||
tools: [calculateTool],
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
await agent.prompt("Calculate 123 * 456 using the calculator tool.");
|
||||
|
|
@ -99,7 +80,6 @@ async function abortExecution(model: Model<any>) {
|
|||
thinkingLevel: "off",
|
||||
tools: [calculateTool],
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
const promptPromise = agent.prompt("Calculate 100 * 200, then 300 * 400, then sum the results.");
|
||||
|
|
@ -129,7 +109,6 @@ async function stateUpdates(model: Model<any>) {
|
|||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
const events: Array<string> = [];
|
||||
|
|
@ -162,7 +141,6 @@ async function multiTurnConversation(model: Model<any>) {
|
|||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
await agent.prompt("My name is Alice.");
|
||||
|
|
@ -356,7 +334,6 @@ describe("Agent.continue()", () => {
|
|||
systemPrompt: "Test",
|
||||
model: getModel("anthropic", "claude-haiku-4-5"),
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
await expect(agent.continue()).rejects.toThrow("No messages to continue from");
|
||||
|
|
@ -368,7 +345,6 @@ describe("Agent.continue()", () => {
|
|||
systemPrompt: "Test",
|
||||
model: getModel("anthropic", "claude-haiku-4-5"),
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
const assistantMessage: AssistantMessage = {
|
||||
|
|
@ -405,7 +381,6 @@ describe("Agent.continue()", () => {
|
|||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
// Manually add a user message without calling prompt()
|
||||
|
|
@ -445,7 +420,6 @@ describe("Agent.continue()", () => {
|
|||
thinkingLevel: "off",
|
||||
tools: [calculateTool],
|
||||
},
|
||||
transport: createTransport(),
|
||||
});
|
||||
|
||||
// Set up a conversation state as if tool was just executed
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import type { AgentTool, AgentToolResult } from "../../agent/types.js";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.js";
|
||||
|
||||
export interface CalculateResult extends AgentToolResult<undefined> {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import type { AgentTool } from "../../agent/index.js";
|
||||
import type { AgentToolResult } from "../types.js";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.js";
|
||||
|
||||
export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {}
|
||||
|
||||
|
|
@ -2,6 +2,26 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Agent API moved**: All agent functionality (`agentLoop`, `agentLoopContinue`, `AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, etc.) has moved to `@mariozechner/pi-agent-core`. Import from that package instead of `@mariozechner/pi-ai`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`GoogleThinkingLevel` type**: Exported type that mirrors Google's `ThinkingLevel` enum values (`"THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"`). Allows configuring Gemini thinking levels without importing from `@google/genai`.
|
||||
- **`ANTHROPIC_OAUTH_TOKEN` env var**: Now checked before `ANTHROPIC_API_KEY` in `getEnvApiKey()`, allowing OAuth tokens to take precedence.
|
||||
- **`event-stream.js` export**: `AssistantMessageEventStream` utility now exported from package index.
|
||||
|
||||
### Changed
|
||||
|
||||
- **OAuth uses Web Crypto API**: PKCE generation and OAuth flows now use Web Crypto API (`crypto.subtle`) instead of Node.js `crypto` module. This improves browser compatibility while still working in Node.js 20+.
|
||||
- **Deterministic model generation**: `generate-models.ts` now sorts providers and models alphabetically for consistent output across runs. ([#332](https://github.com/badlogic/pi-mono/pull/332) by [@mrexodia](https://github.com/mrexodia))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenAI completions empty content blocks**: Empty text or thinking blocks in assistant messages are now filtered out before sending to the OpenAI completions API, preventing validation errors. ([#344](https://github.com/badlogic/pi-mono/pull/344) by [@default-anton](https://github.com/default-anton))
|
||||
- **zAi provider API mapping**: Fixed zAi models to use `openai-completions` API with correct base URL (`https://api.z.ai/api/coding/paas/v4`) instead of incorrect Anthropic API mapping. ([#344](https://github.com/badlogic/pi-mono/pull/344), [#358](https://github.com/badlogic/pi-mono/pull/358) by [@default-anton](https://github.com/default-anton))
|
||||
|
||||
## [0.28.0] - 2025-12-25
|
||||
|
||||
### Breaking Changes
|
||||
|
|
|
|||
|
|
@ -782,276 +782,6 @@ const continuation = await complete(newModel, restored);
|
|||
|
||||
> **Note**: If the context contains images (encoded as base64 as shown in the Image Input section), those will also be serialized.
|
||||
|
||||
## Agent API
|
||||
|
||||
The Agent API provides a higher-level interface for building agents with tools. It handles tool execution, validation, and provides detailed event streaming for interactive applications.
|
||||
|
||||
### Event System
|
||||
|
||||
The Agent API streams events during execution, allowing you to build reactive UIs and track agent progress. The agent processes prompts in **turns**, where each turn consists of:
|
||||
1. An assistant message (the LLM's response)
|
||||
2. Optional tool executions if the assistant calls tools
|
||||
3. Tool result messages that are fed back to the LLM
|
||||
|
||||
This continues until the assistant produces a response without tool calls.
|
||||
|
||||
**Queued messages**: If you provide `getQueuedMessages` in the loop config, the agent checks for queued user messages after each tool call. When queued messages are found, any remaining tool calls from the current assistant message are skipped and returned as error tool results (`isError: true`) with the message "Skipped due to queued user message." The queued user messages are injected before the next assistant response.
|
||||
|
||||
### Event Flow Example
|
||||
|
||||
Given a prompt asking to calculate two expressions and sum them:
|
||||
|
||||
```typescript
|
||||
import { agentLoop, AgentContext, calculateTool } from '@mariozechner/pi-ai';
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: 'You are a helpful math assistant.',
|
||||
messages: [],
|
||||
tools: [calculateTool]
|
||||
};
|
||||
|
||||
const stream = agentLoop(
|
||||
{ role: 'user', content: 'Calculate 15 * 20 and 30 * 40, then sum the results', timestamp: Date.now() },
|
||||
context,
|
||||
{ model: getModel('openai', 'gpt-4o-mini') }
|
||||
);
|
||||
|
||||
// Expected event sequence:
|
||||
// 1. agent_start - Agent begins processing
|
||||
// 2. turn_start - First turn begins
|
||||
// 3. message_start - User message starts
|
||||
// 4. message_end - User message ends
|
||||
// 5. message_start - Assistant message starts
|
||||
// 6. message_update - Assistant streams response with tool calls
|
||||
// 7. message_end - Assistant message ends
|
||||
// 8. tool_execution_start - First calculation (15 * 20)
|
||||
// 9. tool_execution_update - Streaming progress (for long-running tools)
|
||||
// 10. tool_execution_end - Result: 300
|
||||
// 11. tool_execution_start - Second calculation (30 * 40)
|
||||
// 12. tool_execution_update - Streaming progress
|
||||
// 13. tool_execution_end - Result: 1200
|
||||
// 12. message_start - Tool result message for first calculation
|
||||
// 13. message_end - Tool result message ends
|
||||
// 14. message_start - Tool result message for second calculation
|
||||
// 15. message_end - Tool result message ends
|
||||
// 16. turn_end - First turn ends with 2 tool results
|
||||
// 17. turn_start - Second turn begins
|
||||
// 18. message_start - Assistant message starts
|
||||
// 19. message_update - Assistant streams response with sum calculation
|
||||
// 20. message_end - Assistant message ends
|
||||
// 21. tool_execution_start - Sum calculation (300 + 1200)
|
||||
// 22. tool_execution_end - Result: 1500
|
||||
// 23. message_start - Tool result message for sum
|
||||
// 24. message_end - Tool result message ends
|
||||
// 25. turn_end - Second turn ends with 1 tool result
|
||||
// 26. turn_start - Third turn begins
|
||||
// 27. message_start - Final assistant message starts
|
||||
// 28. message_update - Assistant streams final answer
|
||||
// 29. message_end - Final assistant message ends
|
||||
// 30. turn_end - Third turn ends with 0 tool results
|
||||
// 31. agent_end - Agent completes with all messages
|
||||
```
|
||||
|
||||
### Handling Events
|
||||
|
||||
```typescript
|
||||
for await (const event of stream) {
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
console.log('Agent started');
|
||||
break;
|
||||
|
||||
case 'turn_start':
|
||||
console.log('New turn started');
|
||||
break;
|
||||
|
||||
case 'message_start':
|
||||
console.log(`${event.message.role} message started`);
|
||||
break;
|
||||
|
||||
case 'message_update':
|
||||
// Only for assistant messages during streaming
|
||||
if (event.message.content.some(c => c.type === 'text')) {
|
||||
console.log('Assistant:', event.message.content);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_execution_start':
|
||||
console.log(`Calling ${event.toolName} with:`, event.args);
|
||||
break;
|
||||
|
||||
case 'tool_execution_update':
|
||||
// Streaming progress for long-running tools (e.g., bash output)
|
||||
console.log(`Progress:`, event.partialResult.content);
|
||||
break;
|
||||
|
||||
case 'tool_execution_end':
|
||||
if (event.isError) {
|
||||
console.error(`Tool failed:`, event.result);
|
||||
} else {
|
||||
console.log(`Tool result:`, event.result.content);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'turn_end':
|
||||
console.log(`Turn ended with ${event.toolResults.length} tool calls`);
|
||||
break;
|
||||
|
||||
case 'agent_end':
|
||||
console.log(`Agent completed with ${event.messages.length} new messages`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all messages generated during this agent execution
|
||||
// These include the user message and can be directly appended to context.messages
|
||||
const messages = await stream.result();
|
||||
context.messages.push(...messages);
|
||||
```
|
||||
|
||||
### Continuing from Existing Context
|
||||
|
||||
Use `agentLoopContinue` to resume an agent loop without adding a new user message. This is useful for:
|
||||
- Retrying after context overflow (after compaction reduces context size)
|
||||
- Resuming from tool results that were added manually to the context
|
||||
|
||||
```typescript
|
||||
import { agentLoopContinue, AgentContext } from '@mariozechner/pi-ai';
|
||||
|
||||
// Context already has messages - last must be 'user' or 'toolResult'
|
||||
const context: AgentContext = {
|
||||
systemPrompt: 'You are helpful.',
|
||||
messages: [userMessage, assistantMessage, toolResult],
|
||||
tools: [myTool]
|
||||
};
|
||||
|
||||
// Continue processing from the tool result
|
||||
const stream = agentLoopContinue(context, { model });
|
||||
|
||||
for await (const event of stream) {
|
||||
// Same events as agentLoop, but no user message events emitted
|
||||
}
|
||||
|
||||
const newMessages = await stream.result();
|
||||
```
|
||||
|
||||
**Validation**: Throws if context has no messages or if the last message is an assistant message.
|
||||
|
||||
### Defining Tools with TypeBox
|
||||
|
||||
Tools use TypeBox schemas for runtime validation and type inference:
|
||||
|
||||
```typescript
|
||||
import { Type, Static, AgentTool, AgentToolResult, StringEnum } from '@mariozechner/pi-ai';
|
||||
|
||||
const weatherSchema = Type.Object({
|
||||
city: Type.String({ minLength: 1 }),
|
||||
units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
|
||||
});
|
||||
|
||||
type WeatherParams = Static<typeof weatherSchema>;
|
||||
|
||||
const weatherTool: AgentTool<typeof weatherSchema, { temp: number }> = {
|
||||
label: 'Get Weather',
|
||||
name: 'get_weather',
|
||||
description: 'Get current weather for a city',
|
||||
parameters: weatherSchema,
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
// args is fully typed: { city: string, units: 'celsius' | 'fahrenheit' }
|
||||
// signal: AbortSignal for cancellation
|
||||
// onUpdate: Optional callback for streaming progress (emits tool_execution_update events)
|
||||
const temp = Math.round(Math.random() * 30);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Temperature in ${args.city}: ${temp}°${args.units[0].toUpperCase()}` }],
|
||||
details: { temp }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Tools can also return images alongside text
|
||||
const chartTool: AgentTool<typeof Type.Object({ data: Type.Array(Type.Number()) })> = {
|
||||
label: 'Generate Chart',
|
||||
name: 'generate_chart',
|
||||
description: 'Generate a chart from data',
|
||||
parameters: Type.Object({ data: Type.Array(Type.Number()) }),
|
||||
execute: async (toolCallId, args) => {
|
||||
const chartImage = await generateChartImage(args.data);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Generated chart with ${args.data.length} data points` },
|
||||
{ type: 'image', data: chartImage.toString('base64'), mimeType: 'image/png' }
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Tools can stream progress via the onUpdate callback (emits tool_execution_update events)
|
||||
const bashTool: AgentTool<typeof Type.Object({ command: Type.String() }), { exitCode: number }> = {
|
||||
label: 'Run Bash',
|
||||
name: 'bash',
|
||||
description: 'Execute a bash command',
|
||||
parameters: Type.Object({ command: Type.String() }),
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
let output = '';
|
||||
const child = spawn('bash', ['-c', args.command]);
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
// Stream partial output to UI via tool_execution_update events
|
||||
onUpdate?.({
|
||||
content: [{ type: 'text', text: output }],
|
||||
details: { exitCode: -1 } // Not finished yet
|
||||
});
|
||||
});
|
||||
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
child.on('close', resolve);
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: output }],
|
||||
details: { exitCode }
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Validation and Error Handling
|
||||
|
||||
Tool arguments are automatically validated using AJV with the TypeBox schema. Invalid arguments result in detailed error messages:
|
||||
|
||||
```typescript
|
||||
// If the LLM calls with invalid arguments:
|
||||
// get_weather({ city: '', units: 'kelvin' })
|
||||
|
||||
// The tool execution will fail with:
|
||||
/*
|
||||
Validation failed for tool "get_weather":
|
||||
- city: must NOT have fewer than 1 characters
|
||||
- units: must be equal to one of the allowed values
|
||||
|
||||
Received arguments:
|
||||
{
|
||||
"city": "",
|
||||
"units": "kelvin"
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
### Built-in Example Tools
|
||||
|
||||
The library includes example tools for common operations:
|
||||
|
||||
```typescript
|
||||
import { calculateTool, getCurrentTimeTool } from '@mariozechner/pi-ai';
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
messages: [],
|
||||
tools: [calculateTool, getCurrentTimeTool]
|
||||
};
|
||||
```
|
||||
|
||||
## Browser Usage
|
||||
|
||||
The library supports browser environments. You must pass the API key explicitly since environment variables are not available in browsers:
|
||||
|
|
|
|||
|
|
@ -258,36 +258,34 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
}
|
||||
}
|
||||
|
||||
// Process xAi models
|
||||
// Process zAi models
|
||||
if (data.zai?.models) {
|
||||
for (const [modelId, model] of Object.entries(data.zai.models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
const supportsImage = m.modalities?.input?.includes("image")
|
||||
for (const [modelId, model] of Object.entries(data.zai.models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
const supportsImage = m.modalities?.input?.includes("image")
|
||||
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: m.name || modelId,
|
||||
api: supportsImage ? "openai-completions" : "anthropic-messages",
|
||||
provider: "zai",
|
||||
baseUrl: supportsImage ? "https://api.z.ai/api/coding/paas/v4" : "https://api.z.ai/api/anthropic",
|
||||
reasoning: m.reasoning === true,
|
||||
input: supportsImage ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
...(supportsImage ? {
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
},
|
||||
} : {}),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
}
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: m.name || modelId,
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
reasoning: m.reasoning === true,
|
||||
input: supportsImage ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
},
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process Mistral models
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
export { agentLoop, agentLoopContinue } from "./agent-loop.js";
|
||||
export * from "./tools/index.js";
|
||||
export type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentLoopConfig,
|
||||
AgentTool,
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
QueuedMessage,
|
||||
} from "./types.js";
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { calculate, calculateTool } from "./calculate.js";
|
||||
export { getCurrentTime, getCurrentTimeTool } from "./get-current-time.js";
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
TextContent,
|
||||
Tool,
|
||||
ToolResultMessage,
|
||||
} from "../types.js";
|
||||
|
||||
export interface AgentToolResult<T> {
|
||||
// Content blocks supporting text and images
|
||||
content: (TextContent | ImageContent)[];
|
||||
// Details to be displayed in a UI or logged
|
||||
details: T;
|
||||
}
|
||||
|
||||
// Callback for streaming tool execution updates
|
||||
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
||||
|
||||
// AgentTool extends Tool but adds the execute function
|
||||
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
|
||||
// A human-readable label for the tool to be displayed in UI
|
||||
label: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>,
|
||||
) => Promise<AgentToolResult<TDetails>>;
|
||||
}
|
||||
|
||||
// AgentContext is like Context but uses AgentTool
|
||||
export interface AgentContext {
|
||||
systemPrompt: string;
|
||||
messages: Message[];
|
||||
tools?: AgentTool<any>[];
|
||||
}
|
||||
|
||||
// Event types
|
||||
export type AgentEvent =
|
||||
// Emitted when the agent starts. An agent can emit multiple turns
|
||||
| { type: "agent_start" }
|
||||
// Emitted when a turn starts. A turn can emit an optional user message (initial prompt), an assistant message (response) and multiple tool result messages
|
||||
| { type: "turn_start" }
|
||||
// Emitted when a user, assistant or tool result message starts
|
||||
| { type: "message_start"; message: Message }
|
||||
// Emitted when an asssitant messages is updated due to streaming
|
||||
| { type: "message_update"; assistantMessageEvent: AssistantMessageEvent; message: AssistantMessage }
|
||||
// Emitted when a user, assistant or tool result message is complete
|
||||
| { type: "message_end"; message: Message }
|
||||
// Emitted when a tool execution starts
|
||||
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
|
||||
// Emitted when a tool execution produces output (streaming)
|
||||
| {
|
||||
type: "tool_execution_update";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: any;
|
||||
partialResult: AgentToolResult<any>;
|
||||
}
|
||||
// Emitted when a tool execution completes
|
||||
| {
|
||||
type: "tool_execution_end";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: AgentToolResult<any>;
|
||||
isError: boolean;
|
||||
}
|
||||
// Emitted when a full turn completes
|
||||
| { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] }
|
||||
// Emitted when the agent has completed all its turns. All messages from every turn are
|
||||
// contained in messages, which can be appended to the context
|
||||
| { type: "agent_end"; messages: AgentContext["messages"] };
|
||||
|
||||
// Queued message with optional LLM representation
|
||||
export interface QueuedMessage<TApp = Message> {
|
||||
original: TApp; // Original message for UI events
|
||||
llm?: Message; // Optional transformed message for loop context (undefined if filtered)
|
||||
}
|
||||
|
||||
// Configuration for agent loop execution
|
||||
export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
model: Model<any>;
|
||||
|
||||
/**
|
||||
* Optional hook to resolve an API key dynamically for each LLM call.
|
||||
*
|
||||
* This is useful for short-lived OAuth tokens (e.g. GitHub Copilot) that may
|
||||
* expire during long-running tool execution phases.
|
||||
*
|
||||
* The agent loop will call this before each assistant response and pass the
|
||||
* returned value as `apiKey` to `streamSimple()` (or a custom `streamFn`).
|
||||
*
|
||||
* If it returns `undefined`, the loop falls back to `config.apiKey`, and then
|
||||
* to `streamSimple()`'s own provider key lookup (setApiKey/env vars).
|
||||
*/
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
|
||||
preprocessor?: (messages: AgentContext["messages"], abortSignal?: AbortSignal) => Promise<AgentContext["messages"]>;
|
||||
getQueuedMessages?: <T>() => Promise<QueuedMessage<T>[]>;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export * from "./agent/index.js";
|
||||
export * from "./models.js";
|
||||
export * from "./providers/anthropic.js";
|
||||
export * from "./providers/google.js";
|
||||
|
|
@ -7,6 +6,7 @@ export * from "./providers/openai-completions.js";
|
|||
export * from "./providers/openai-responses.js";
|
||||
export * from "./stream.js";
|
||||
export * from "./types.js";
|
||||
export * from "./utils/event-stream.js";
|
||||
export * from "./utils/oauth/index.js";
|
||||
export * from "./utils/overflow.js";
|
||||
export * from "./utils/typebox-helpers.js";
|
||||
|
|
|
|||
|
|
@ -3325,13 +3325,13 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.224,
|
||||
output: 0.32,
|
||||
input: 0.25,
|
||||
output: 0.38,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 163840,
|
||||
maxTokens: 4096,
|
||||
maxTokens: 65536,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"deepseek/deepseek-v3.2-exp": {
|
||||
id: "deepseek/deepseek-v3.2-exp",
|
||||
|
|
@ -3892,7 +3892,7 @@ export const MODELS = {
|
|||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 196608,
|
||||
maxTokens: 131072,
|
||||
maxTokens: 65536,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"minimax/minimax-m2.1": {
|
||||
id: "minimax/minimax-m2.1",
|
||||
|
|
@ -5371,7 +5371,7 @@ export const MODELS = {
|
|||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 128000,
|
||||
maxTokens: 131072,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"openai/gpt-oss-safeguard-20b": {
|
||||
id: "openai/gpt-oss-safeguard-20b",
|
||||
|
|
@ -6249,8 +6249,8 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
input: 0.25,
|
||||
output: 0.85,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
|
|
@ -6266,8 +6266,8 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
input: 0.25,
|
||||
output: 0.85,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
|
|
@ -6538,13 +6538,13 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.39,
|
||||
output: 1.9,
|
||||
input: 0.35,
|
||||
output: 1.5,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 204800,
|
||||
maxTokens: 204800,
|
||||
contextWindow: 202752,
|
||||
maxTokens: 65536,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"z-ai/glm-4.6:exacto": {
|
||||
id: "z-ai/glm-4.6:exacto",
|
||||
|
|
@ -6978,9 +6978,10 @@ export const MODELS = {
|
|||
"glm-4.5": {
|
||||
id: "glm-4.5",
|
||||
name: "GLM-4.5",
|
||||
api: "anthropic-messages",
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/anthropic",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
compat: {"supportsDeveloperRole":false},
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
|
|
@ -6991,13 +6992,14 @@ export const MODELS = {
|
|||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 98304,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"glm-4.5-air": {
|
||||
id: "glm-4.5-air",
|
||||
name: "GLM-4.5-Air",
|
||||
api: "anthropic-messages",
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/anthropic",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
compat: {"supportsDeveloperRole":false},
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
|
|
@ -7008,13 +7010,14 @@ export const MODELS = {
|
|||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 98304,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"glm-4.5-flash": {
|
||||
id: "glm-4.5-flash",
|
||||
name: "GLM-4.5-Flash",
|
||||
api: "anthropic-messages",
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/anthropic",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
compat: {"supportsDeveloperRole":false},
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
|
|
@ -7025,7 +7028,7 @@ export const MODELS = {
|
|||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 98304,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"glm-4.5v": {
|
||||
id: "glm-4.5v",
|
||||
name: "GLM-4.5V",
|
||||
|
|
@ -7047,9 +7050,10 @@ export const MODELS = {
|
|||
"glm-4.6": {
|
||||
id: "glm-4.6",
|
||||
name: "GLM-4.6",
|
||||
api: "anthropic-messages",
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/anthropic",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
compat: {"supportsDeveloperRole":false},
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
|
|
@ -7060,7 +7064,7 @@ export const MODELS = {
|
|||
},
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"glm-4.6v": {
|
||||
id: "glm-4.6v",
|
||||
name: "GLM-4.6V",
|
||||
|
|
@ -7082,9 +7086,10 @@ export const MODELS = {
|
|||
"glm-4.7": {
|
||||
id: "glm-4.7",
|
||||
name: "GLM-4.7",
|
||||
api: "anthropic-messages",
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/anthropic",
|
||||
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||
compat: {"supportsDeveloperRole":false},
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
|
|
@ -7095,6 +7100,6 @@ export const MODELS = {
|
|||
},
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
} satisfies Model<"openai-completions">,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Uses the Cloud Code Assist API endpoint to access Gemini and Claude models.
|
||||
*/
|
||||
|
||||
import type { Content, ThinkingConfig, ThinkingLevel } from "@google/genai";
|
||||
import type { Content, ThinkingConfig } from "@google/genai";
|
||||
import { calculateCost } from "../models.js";
|
||||
import type {
|
||||
Api,
|
||||
|
|
@ -21,6 +21,12 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
|||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { convertMessages, convertTools, mapStopReasonString, mapToolChoice } from "./google-shared.js";
|
||||
|
||||
/**
|
||||
* Thinking level for Gemini 3 models.
|
||||
* Mirrors Google's ThinkingLevel enum values.
|
||||
*/
|
||||
export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
|
||||
|
||||
export interface GoogleGeminiCliOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "any";
|
||||
/**
|
||||
|
|
@ -35,7 +41,7 @@ export interface GoogleGeminiCliOptions extends StreamOptions {
|
|||
/** Thinking budget in tokens. Use for Gemini 2.x models. */
|
||||
budgetTokens?: number;
|
||||
/** Thinking level. Use for Gemini 3 models (LOW/HIGH for Pro, MINIMAL/LOW/MEDIUM/HIGH for Flash). */
|
||||
level?: ThinkingLevel;
|
||||
level?: GoogleThinkingLevel;
|
||||
};
|
||||
projectId?: string;
|
||||
}
|
||||
|
|
@ -436,7 +442,8 @@ function buildRequest(
|
|||
};
|
||||
// Gemini 3 models use thinkingLevel, older models use thinkingBudget
|
||||
if (options.thinking.level !== undefined) {
|
||||
generationConfig.thinkingConfig.thinkingLevel = options.thinking.level;
|
||||
// Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values
|
||||
generationConfig.thinkingConfig.thinkingLevel = options.thinking.level as any;
|
||||
} else if (options.thinking.budgetTokens !== undefined) {
|
||||
generationConfig.thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
type GenerateContentParameters,
|
||||
GoogleGenAI,
|
||||
type ThinkingConfig,
|
||||
type ThinkingLevel,
|
||||
} from "@google/genai";
|
||||
import { calculateCost } from "../models.js";
|
||||
import { getEnvApiKey } from "../stream.js";
|
||||
|
|
@ -20,6 +19,7 @@ import type {
|
|||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import type { GoogleThinkingLevel } from "./google-gemini-cli.js";
|
||||
import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js";
|
||||
|
||||
export interface GoogleOptions extends StreamOptions {
|
||||
|
|
@ -27,7 +27,7 @@ export interface GoogleOptions extends StreamOptions {
|
|||
thinking?: {
|
||||
enabled: boolean;
|
||||
budgetTokens?: number; // -1 for dynamic, 0 to disable
|
||||
level?: ThinkingLevel;
|
||||
level?: GoogleThinkingLevel;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -299,7 +299,8 @@ function buildParams(
|
|||
if (options.thinking?.enabled && model.reasoning) {
|
||||
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
|
||||
if (options.thinking.level !== undefined) {
|
||||
thinkingConfig.thinkingLevel = options.thinking.level;
|
||||
// Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values
|
||||
thinkingConfig.thinkingLevel = options.thinking.level as any;
|
||||
} else if (options.thinking.budgetTokens !== undefined) {
|
||||
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,13 +460,15 @@ function convertMessages(
|
|||
};
|
||||
|
||||
const textBlocks = msg.content.filter((b) => b.type === "text") as TextContent[];
|
||||
if (textBlocks.length > 0) {
|
||||
// Filter out empty text blocks to avoid API validation errors
|
||||
const nonEmptyTextBlocks = textBlocks.filter((b) => b.text && b.text.trim().length > 0);
|
||||
if (nonEmptyTextBlocks.length > 0) {
|
||||
// GitHub Copilot requires assistant content as a string, not an array.
|
||||
// Sending as array causes Claude models to re-answer all previous prompts.
|
||||
if (model.provider === "github-copilot") {
|
||||
assistantMsg.content = textBlocks.map((b) => sanitizeSurrogates(b.text)).join("");
|
||||
assistantMsg.content = nonEmptyTextBlocks.map((b) => sanitizeSurrogates(b.text)).join("");
|
||||
} else {
|
||||
assistantMsg.content = textBlocks.map((b) => {
|
||||
assistantMsg.content = nonEmptyTextBlocks.map((b) => {
|
||||
return { type: "text", text: sanitizeSurrogates(b.text) };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { ThinkingLevel } from "@google/genai";
|
||||
import { supportsXhigh } from "./models.js";
|
||||
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js";
|
||||
import { type GoogleOptions, streamGoogle } from "./providers/google.js";
|
||||
import { type GoogleGeminiCliOptions, streamGoogleGeminiCli } from "./providers/google-gemini-cli.js";
|
||||
import {
|
||||
type GoogleGeminiCliOptions,
|
||||
type GoogleThinkingLevel,
|
||||
streamGoogleGeminiCli,
|
||||
} from "./providers/google-gemini-cli.js";
|
||||
import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions.js";
|
||||
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses.js";
|
||||
import type {
|
||||
|
|
@ -30,9 +33,13 @@ export function getEnvApiKey(provider: any): string | undefined {
|
|||
return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||
}
|
||||
|
||||
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
|
||||
if (provider === "anthropic") {
|
||||
return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
|
||||
const envMap: Record<string, string> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
|
|
@ -252,53 +259,56 @@ function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
|||
return model.id.includes("3-flash");
|
||||
}
|
||||
|
||||
function getGemini3ThinkingLevel(effort: ClampedReasoningEffort, model: Model<"google-generative-ai">): ThinkingLevel {
|
||||
function getGemini3ThinkingLevel(
|
||||
effort: ClampedReasoningEffort,
|
||||
model: Model<"google-generative-ai">,
|
||||
): GoogleThinkingLevel {
|
||||
if (isGemini3ProModel(model)) {
|
||||
// Gemini 3 Pro only supports LOW/HIGH (for now)
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
return ThinkingLevel.LOW;
|
||||
return "LOW";
|
||||
case "medium":
|
||||
case "high":
|
||||
return ThinkingLevel.HIGH;
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
// Gemini 3 Flash supports all four levels
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
return ThinkingLevel.MINIMAL;
|
||||
return "MINIMAL";
|
||||
case "low":
|
||||
return ThinkingLevel.LOW;
|
||||
return "LOW";
|
||||
case "medium":
|
||||
return ThinkingLevel.MEDIUM;
|
||||
return "MEDIUM";
|
||||
case "high":
|
||||
return ThinkingLevel.HIGH;
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
|
||||
function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: string): ThinkingLevel {
|
||||
function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: string): GoogleThinkingLevel {
|
||||
if (modelId.includes("3-pro")) {
|
||||
// Gemini 3 Pro only supports LOW/HIGH (for now)
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
return ThinkingLevel.LOW;
|
||||
return "LOW";
|
||||
case "medium":
|
||||
case "high":
|
||||
return ThinkingLevel.HIGH;
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
// Gemini 3 Flash supports all four levels
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
return ThinkingLevel.MINIMAL;
|
||||
return "MINIMAL";
|
||||
case "low":
|
||||
return ThinkingLevel.LOW;
|
||||
return "LOW";
|
||||
case "medium":
|
||||
return ThinkingLevel.MEDIUM;
|
||||
return "MEDIUM";
|
||||
case "high":
|
||||
return ThinkingLevel.HIGH;
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,25 +2,16 @@
|
|||
* Anthropic OAuth flow (Claude Pro/Max)
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { generatePKCE } from "./pkce.js";
|
||||
import type { OAuthCredentials } from "./types.js";
|
||||
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
||||
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
|
||||
const SCOPES = "org:create_api_key user:profile user:inference";
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
function generatePKCE(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Anthropic OAuth (device code flow)
|
||||
*
|
||||
|
|
@ -31,7 +22,7 @@ export async function loginAnthropic(
|
|||
onAuthUrl: (url: string) => void,
|
||||
onPromptCode: () => Promise<string>,
|
||||
): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generatePKCE();
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
|
||||
// Build authorization URL
|
||||
const authParams = new URLSearchParams({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { getModels } from "../../models.js";
|
||||
import type { OAuthCredentials } from "./types.js";
|
||||
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
|
||||
|
||||
const COPILOT_HEADERS = {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
/**
|
||||
* Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
|
||||
* Uses different OAuth credentials than google-gemini-cli for access to additional models.
|
||||
*
|
||||
* NOTE: This module uses Node.js http.createServer for the OAuth callback.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { createServer, type Server } from "http";
|
||||
import type { Server } from "http";
|
||||
import { generatePKCE } from "./pkce.js";
|
||||
import type { OAuthCredentials } from "./types.js";
|
||||
|
||||
// Antigravity OAuth credentials (different from Gemini CLI)
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
);
|
||||
|
|
@ -30,19 +33,15 @@ const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|||
// Fallback project ID when discovery fails
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
function generatePKCE(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local HTTP server to receive the OAuth callback
|
||||
*/
|
||||
function startCallbackServer(): Promise<{ server: Server; getCode: () => Promise<{ code: string; state: string }> }> {
|
||||
async function startCallbackServer(): Promise<{
|
||||
server: Server;
|
||||
getCode: () => Promise<{ code: string; state: string }>;
|
||||
}> {
|
||||
const { createServer } = await import("http");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let codeResolve: (value: { code: string; state: string }) => void;
|
||||
let codeReject: (error: Error) => void;
|
||||
|
|
@ -232,7 +231,7 @@ export async function loginAntigravity(
|
|||
onAuth: (info: { url: string; instructions?: string }) => void,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generatePKCE();
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
|
||||
// Start local server for callback
|
||||
onProgress?.("Starting local server for OAuth callback...");
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
/**
|
||||
* Gemini CLI OAuth flow (Google Cloud Code Assist)
|
||||
* Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*)
|
||||
*
|
||||
* NOTE: This module uses Node.js http.createServer for the OAuth callback.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { createServer, type Server } from "http";
|
||||
import type { Server } from "http";
|
||||
import { generatePKCE } from "./pkce.js";
|
||||
import type { OAuthCredentials } from "./types.js";
|
||||
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode(
|
||||
"NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t",
|
||||
);
|
||||
|
|
@ -22,19 +25,15 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
function generatePKCE(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local HTTP server to receive the OAuth callback
|
||||
*/
|
||||
function startCallbackServer(): Promise<{ server: Server; getCode: () => Promise<{ code: string; state: string }> }> {
|
||||
async function startCallbackServer(): Promise<{
|
||||
server: Server;
|
||||
getCode: () => Promise<{ code: string; state: string }>;
|
||||
}> {
|
||||
const { createServer } = await import("http");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let codeResolve: (value: { code: string; state: string }) => void;
|
||||
let codeReject: (error: Error) => void;
|
||||
|
|
@ -263,7 +262,7 @@ export async function loginGeminiCli(
|
|||
onAuth: (info: { url: string; instructions?: string }) => void,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generatePKCE();
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
|
||||
// Start local server for callback
|
||||
onProgress?.("Starting local server for OAuth callback...");
|
||||
|
|
|
|||
34
packages/ai/src/utils/oauth/pkce.ts
Normal file
34
packages/ai/src/utils/oauth/pkce.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* PKCE utilities using Web Crypto API.
|
||||
* Works in both Node.js 20+ and browsers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode bytes as base64url string.
|
||||
*/
|
||||
function base64urlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge.
|
||||
* Uses Web Crypto API for cross-platform compatibility.
|
||||
*/
|
||||
export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
||||
// Generate random verifier
|
||||
const verifierBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(verifierBytes);
|
||||
const verifier = base64urlEncode(verifierBytes);
|
||||
|
||||
// Compute SHA-256 challenge
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const challenge = base64urlEncode(new Uint8Array(hashBuffer));
|
||||
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentLoop } from "../src/agent/agent-loop.js";
|
||||
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, QueuedMessage } from "../src/agent/types.js";
|
||||
import type { AssistantMessage, Message, Model, UserMessage } from "../src/types.js";
|
||||
import { AssistantMessageEventStream } from "../src/utils/event-stream.js";
|
||||
|
||||
function createUsage() {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(): Model<"openai-responses"> {
|
||||
return {
|
||||
id: "mock",
|
||||
name: "mock",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
};
|
||||
}
|
||||
|
||||
describe("agentLoop queued message interrupt", () => {
|
||||
it("injects queued messages after a tool call and skips remaining tool calls", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const executed: string[] = [];
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
executed.push(params.value);
|
||||
return {
|
||||
content: [{ type: "text", text: `ok:${params.value}` }],
|
||||
details: { value: params.value },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
||||
const userPrompt: UserMessage = {
|
||||
role: "user",
|
||||
content: "start",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const queuedUserMessage: Message = {
|
||||
role: "user",
|
||||
content: "interrupt",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const queuedMessages: QueuedMessage<Message>[] = [{ original: queuedUserMessage, llm: queuedUserMessage }];
|
||||
|
||||
let queuedDelivered = false;
|
||||
let sawInterruptInContext = false;
|
||||
let callIndex = 0;
|
||||
|
||||
const streamFn = () => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
if (callIndex === 0) {
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
|
||||
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
|
||||
],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "mock",
|
||||
usage: createUsage(),
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
} else {
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "mock",
|
||||
usage: createUsage(),
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
}
|
||||
callIndex += 1;
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const getQueuedMessages: AgentLoopConfig["getQueuedMessages"] = async <T>() => {
|
||||
if (executed.length === 1 && !queuedDelivered) {
|
||||
queuedDelivered = true;
|
||||
return queuedMessages as QueuedMessage<T>[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
getQueuedMessages,
|
||||
};
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoop(userPrompt, context, config, undefined, (_model, ctx, _options) => {
|
||||
if (callIndex === 1) {
|
||||
sawInterruptInContext = ctx.messages.some(
|
||||
(m) => m.role === "user" && typeof m.content === "string" && m.content === "interrupt",
|
||||
);
|
||||
}
|
||||
return streamFn();
|
||||
});
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(executed).toEqual(["first"]);
|
||||
const toolEnds = events.filter(
|
||||
(event): event is Extract<AgentEvent, { type: "tool_execution_end" }> => event.type === "tool_execution_end",
|
||||
);
|
||||
expect(toolEnds.length).toBe(2);
|
||||
expect(toolEnds[1].isError).toBe(true);
|
||||
expect(toolEnds[1].result.content[0]?.type).toBe("text");
|
||||
if (toolEnds[1].result.content[0]?.type === "text") {
|
||||
expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message");
|
||||
}
|
||||
|
||||
const firstTurnEndIndex = events.findIndex((event) => event.type === "turn_end");
|
||||
const queuedMessageIndex = events.findIndex(
|
||||
(event) =>
|
||||
event.type === "message_start" &&
|
||||
event.message.role === "user" &&
|
||||
typeof event.message.content === "string" &&
|
||||
event.message.content === "interrupt",
|
||||
);
|
||||
const nextAssistantIndex = events.findIndex(
|
||||
(event, index) =>
|
||||
index > queuedMessageIndex && event.type === "message_start" && event.message.role === "assistant",
|
||||
);
|
||||
|
||||
expect(queuedMessageIndex).toBeGreaterThan(firstTurnEndIndex);
|
||||
expect(queuedMessageIndex).toBeLessThan(nextAssistantIndex);
|
||||
expect(sawInterruptInContext).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,701 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { agentLoop, agentLoopContinue } from "../src/agent/agent-loop.js";
|
||||
import { calculateTool } from "../src/agent/tools/calculate.js";
|
||||
import type { AgentContext, AgentEvent, AgentLoopConfig } from "../src/agent/types.js";
|
||||
import { getModel } from "../src/models.js";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Message,
|
||||
Model,
|
||||
OptionsForApi,
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from "../src/types.js";
|
||||
import { resolveApiKey } from "./oauth.js";
|
||||
|
||||
// Resolve OAuth tokens at module level (async, runs before tests)
|
||||
const oauthTokens = await Promise.all([
|
||||
resolveApiKey("anthropic"),
|
||||
resolveApiKey("github-copilot"),
|
||||
resolveApiKey("google-gemini-cli"),
|
||||
resolveApiKey("google-antigravity"),
|
||||
]);
|
||||
const [anthropicOAuthToken, githubCopilotToken, geminiCliToken, antigravityToken] = oauthTokens;
|
||||
|
||||
async function calculateTest<TApi extends Api>(model: Model<TApi>, options: OptionsForApi<TApi> = {}) {
|
||||
// Create the agent context with the calculator tool
|
||||
const context: AgentContext = {
|
||||
systemPrompt:
|
||||
"You are a helpful assistant that performs mathematical calculations. When asked to calculate multiple expressions, you can use parallel tool calls if the model supports it. In your final answer, output ONLY the final sum as a single integer number, nothing else.",
|
||||
messages: [],
|
||||
tools: [calculateTool],
|
||||
};
|
||||
|
||||
// Create the prompt config
|
||||
const config: AgentLoopConfig = {
|
||||
model,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Create the user prompt asking for multiple calculations
|
||||
const userPrompt: UserMessage = {
|
||||
role: "user",
|
||||
content: `Use the calculator tool to complete the following mulit-step task.
|
||||
1. Calculate 3485 * 4234 and 88823 * 3482 in parallel
|
||||
2. Calculate the sum of the two results using the calculator tool
|
||||
3. Output ONLY the final sum as a single integer number, nothing else.`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Calculate expected results (using integers)
|
||||
const expectedFirst = 3485 * 4234; // = 14755490
|
||||
const expectedSecond = 88823 * 3482; // = 309281786
|
||||
const expectedSum = expectedFirst + expectedSecond; // = 324037276
|
||||
|
||||
// Track events for verification
|
||||
const events: AgentEvent[] = [];
|
||||
let turns = 0;
|
||||
let toolCallCount = 0;
|
||||
const toolResults: number[] = [];
|
||||
let finalAnswer: number | undefined;
|
||||
|
||||
// Execute the prompt
|
||||
const stream = agentLoop(userPrompt, context, config);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
|
||||
switch (event.type) {
|
||||
case "turn_start":
|
||||
turns++;
|
||||
console.log(`\n=== Turn ${turns} started ===`);
|
||||
break;
|
||||
|
||||
case "turn_end":
|
||||
console.log(`=== Turn ${turns} ended with ${event.toolResults.length} tool results ===`);
|
||||
console.log(event.message);
|
||||
break;
|
||||
|
||||
case "tool_execution_end":
|
||||
if (!event.isError && typeof event.result === "object" && event.result.content) {
|
||||
const textOutput = event.result.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n");
|
||||
toolCallCount++;
|
||||
// Extract number from output like "expression = result"
|
||||
const match = textOutput.match(/=\s*([\d.]+)/);
|
||||
if (match) {
|
||||
const value = parseFloat(match[1]);
|
||||
toolResults.push(value);
|
||||
console.log(`Tool ${toolCallCount}: ${textOutput}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "message_end":
|
||||
// Just track the message end event, don't extract answer here
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the final messages
|
||||
const finalMessages = await stream.result();
|
||||
|
||||
// Verify the results
|
||||
expect(finalMessages).toBeDefined();
|
||||
expect(finalMessages.length).toBeGreaterThan(0);
|
||||
|
||||
const finalMessage = finalMessages[finalMessages.length - 1];
|
||||
expect(finalMessage).toBeDefined();
|
||||
expect(finalMessage.role).toBe("assistant");
|
||||
if (finalMessage.role !== "assistant") throw new Error("Final message is not from assistant");
|
||||
|
||||
// Extract the final answer from the last assistant message
|
||||
const content = finalMessage.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => (c.type === "text" ? c.text : ""))
|
||||
.join(" ");
|
||||
|
||||
// Look for integers in the response that might be the final answer
|
||||
const numbers = content.match(/\b\d+\b/g);
|
||||
if (numbers) {
|
||||
// Check if any of the numbers matches our expected sum
|
||||
for (const num of numbers) {
|
||||
const value = parseInt(num, 10);
|
||||
if (Math.abs(value - expectedSum) < 10) {
|
||||
finalAnswer = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no exact match, take the last large number as likely the answer
|
||||
if (finalAnswer === undefined) {
|
||||
const largeNumbers = numbers.map((n) => parseInt(n, 10)).filter((n) => n > 1000000);
|
||||
if (largeNumbers.length > 0) {
|
||||
finalAnswer = largeNumbers[largeNumbers.length - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should have executed at least 3 tool calls: 2 for the initial calculations, 1 for the sum
|
||||
// (or possibly 2 if the model calculates the sum itself without a tool)
|
||||
expect(toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Must be at least 3 turns: first to calculate the expressions, then to sum them, then give the answer
|
||||
// Could be 3 turns if model does parallel calls, or 4 turns if sequential calculation of expressions
|
||||
expect(turns).toBeGreaterThanOrEqual(3);
|
||||
expect(turns).toBeLessThanOrEqual(4);
|
||||
|
||||
// Verify the individual calculations are in the results
|
||||
const hasFirstCalc = toolResults.some((r) => r === expectedFirst);
|
||||
const hasSecondCalc = toolResults.some((r) => r === expectedSecond);
|
||||
expect(hasFirstCalc).toBe(true);
|
||||
expect(hasSecondCalc).toBe(true);
|
||||
|
||||
// Verify the final sum
|
||||
if (finalAnswer !== undefined) {
|
||||
expect(finalAnswer).toBe(expectedSum);
|
||||
console.log(`Final answer: ${finalAnswer} (expected: ${expectedSum})`);
|
||||
} else {
|
||||
// If we couldn't extract the final answer from text, check if it's in the tool results
|
||||
const hasSum = toolResults.some((r) => r === expectedSum);
|
||||
expect(hasSum).toBe(true);
|
||||
}
|
||||
|
||||
// Log summary
|
||||
console.log(`\nTest completed with ${turns} turns and ${toolCallCount} tool calls`);
|
||||
if (turns === 3) {
|
||||
console.log("Model used parallel tool calls for initial calculations");
|
||||
} else {
|
||||
console.log("Model used sequential tool calls");
|
||||
}
|
||||
|
||||
return {
|
||||
turns,
|
||||
toolCallCount,
|
||||
toolResults,
|
||||
finalAnswer,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
async function abortTest<TApi extends Api>(model: Model<TApi>, options: OptionsForApi<TApi> = {}) {
|
||||
// Create the agent context with the calculator tool
|
||||
const context: AgentContext = {
|
||||
systemPrompt:
|
||||
"You are a helpful assistant that performs mathematical calculations. Always use the calculator tool for each calculation.",
|
||||
messages: [],
|
||||
tools: [calculateTool],
|
||||
};
|
||||
|
||||
// Create the prompt config
|
||||
const config: AgentLoopConfig = {
|
||||
model,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Create a prompt that will require multiple calculations
|
||||
const userPrompt: UserMessage = {
|
||||
role: "user",
|
||||
content: "Calculate 100 * 200, then 300 * 400, then 500 * 600, then sum all three results.",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Create abort controller
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Track events for verification
|
||||
const events: AgentEvent[] = [];
|
||||
let toolCallCount = 0;
|
||||
const errorReceived = false;
|
||||
let finalMessages: Message[] | undefined;
|
||||
|
||||
// Execute the prompt
|
||||
const stream = agentLoop(userPrompt, context, config, abortController.signal);
|
||||
|
||||
// Abort after first tool execution
|
||||
(async () => {
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
|
||||
if (event.type === "tool_execution_end" && !event.isError) {
|
||||
toolCallCount++;
|
||||
// Abort after first successful tool execution
|
||||
if (toolCallCount === 1) {
|
||||
console.log("Aborting after first tool execution");
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "agent_end") {
|
||||
finalMessages = event.messages;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
finalMessages = await stream.result();
|
||||
|
||||
// Verify abort behavior
|
||||
console.log(`\nAbort test completed with ${toolCallCount} tool calls`);
|
||||
const assistantMessage = finalMessages[finalMessages.length - 1];
|
||||
if (!assistantMessage) throw new Error("No final message received");
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage.role).toBe("assistant");
|
||||
if (assistantMessage.role !== "assistant") throw new Error("Final message is not from assistant");
|
||||
|
||||
// Should have executed 1 tool call before abort
|
||||
expect(toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
expect(assistantMessage.stopReason).toBe("aborted");
|
||||
|
||||
return {
|
||||
toolCallCount,
|
||||
events,
|
||||
errorReceived,
|
||||
finalMessages,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Agent Calculator Tests", () => {
|
||||
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider Agent", () => {
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions Provider Agent", () => {
|
||||
const model = getModel("openai", "gpt-4o-mini");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider Agent", () => {
|
||||
const model = getModel("openai", "gpt-5-mini");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider Agent", () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider Agent", () => {
|
||||
const model = getModel("xai", "grok-3");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.GROQ_API_KEY)("Groq Provider Agent", () => {
|
||||
const model = getModel("groq", "openai/gpt-oss-20b");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider Agent", () => {
|
||||
const model = getModel("cerebras", "gpt-oss-120b");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Agent", () => {
|
||||
const model = getModel("zai", "glm-4.5-air");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Agent", () => {
|
||||
const model = getModel("mistral", "devstral-medium-latest");
|
||||
|
||||
it("should calculate multiple expressions and sum the results", { retry: 3 }, async () => {
|
||||
const result = await calculateTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model);
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
||||
// =========================================================================
|
||||
|
||||
describe("Anthropic OAuth Provider Agent", () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
|
||||
it.skipIf(!anthropicOAuthToken)(
|
||||
"should calculate multiple expressions and sum the results",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const result = await calculateTest(model, { apiKey: anthropicOAuthToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!anthropicOAuthToken)("should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const result = await abortTest(model, { apiKey: anthropicOAuthToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub Copilot Provider Agent", () => {
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"gpt-4o - should calculate multiple expressions and sum the results",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("github-copilot", "gpt-4o");
|
||||
const result = await calculateTest(model, { apiKey: githubCopilotToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)("gpt-4o - should handle abort during tool execution", { retry: 3 }, async () => {
|
||||
const model = getModel("github-copilot", "gpt-4o");
|
||||
const result = await abortTest(model, { apiKey: githubCopilotToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"claude-sonnet-4 - should calculate multiple expressions and sum the results",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("github-copilot", "claude-sonnet-4");
|
||||
const result = await calculateTest(model, { apiKey: githubCopilotToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!githubCopilotToken)(
|
||||
"claude-sonnet-4 - should handle abort during tool execution",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("github-copilot", "claude-sonnet-4");
|
||||
const result = await abortTest(model, { apiKey: githubCopilotToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Google Gemini CLI Provider Agent", () => {
|
||||
it.skipIf(!geminiCliToken)(
|
||||
"gemini-2.5-flash - should calculate multiple expressions and sum the results",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("google-gemini-cli", "gemini-2.5-flash");
|
||||
const result = await calculateTest(model, { apiKey: geminiCliToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!geminiCliToken)(
|
||||
"gemini-2.5-flash - should handle abort during tool execution",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("google-gemini-cli", "gemini-2.5-flash");
|
||||
const result = await abortTest(model, { apiKey: geminiCliToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Google Antigravity Provider Agent", () => {
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gemini-3-flash - should calculate multiple expressions and sum the results",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("google-antigravity", "gemini-3-flash");
|
||||
const result = await calculateTest(model, { apiKey: antigravityToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gemini-3-flash - should handle abort during tool execution",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("google-antigravity", "gemini-3-flash");
|
||||
const result = await abortTest(model, { apiKey: antigravityToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"claude-sonnet-4-5 - should calculate multiple expressions and sum the results",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("google-antigravity", "claude-sonnet-4-5");
|
||||
const result = await calculateTest(model, { apiKey: antigravityToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"claude-sonnet-4-5 - should handle abort during tool execution",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("google-antigravity", "claude-sonnet-4-5");
|
||||
const result = await abortTest(model, { apiKey: antigravityToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gpt-oss-120b-medium - should calculate multiple expressions and sum the results",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("google-antigravity", "gpt-oss-120b-medium");
|
||||
const result = await calculateTest(model, { apiKey: antigravityToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!antigravityToken)(
|
||||
"gpt-oss-120b-medium - should handle abort during tool execution",
|
||||
{ retry: 3 },
|
||||
async () => {
|
||||
const model = getModel("google-antigravity", "gpt-oss-120b-medium");
|
||||
const result = await abortTest(model, { apiKey: antigravityToken });
|
||||
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentLoopContinue", () => {
|
||||
describe("validation", () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const baseContext: AgentContext = {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
const config: AgentLoopConfig = { model };
|
||||
|
||||
it("should throw when context has no messages", () => {
|
||||
expect(() => agentLoopContinue(baseContext, config)).toThrow("Cannot continue: no messages in context");
|
||||
});
|
||||
|
||||
it("should throw when last message is an assistant message", () => {
|
||||
const assistantMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-4-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const context: AgentContext = {
|
||||
...baseContext,
|
||||
messages: [assistantMessage],
|
||||
};
|
||||
expect(() => agentLoopContinue(context, config)).toThrow(
|
||||
"Cannot continue from message role: assistant. Expected 'user' or 'toolResult'.",
|
||||
);
|
||||
});
|
||||
|
||||
// Note: "should not throw" tests for valid inputs are covered by the E2E tests below
|
||||
// which actually consume the stream and verify the output
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from user message", () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
|
||||
it("should continue and get assistant response when last message is user", { retry: 3 }, async () => {
|
||||
const userMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Say exactly: HELLO WORLD" }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
|
||||
messages: [userMessage],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = { model };
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoopContinue(context, config);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const messages = await stream.result();
|
||||
|
||||
// Should have gotten an assistant response
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0].role).toBe("assistant");
|
||||
|
||||
// Verify event sequence - no user message events since we're continuing
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
expect(eventTypes).toContain("agent_start");
|
||||
expect(eventTypes).toContain("turn_start");
|
||||
expect(eventTypes).toContain("message_start");
|
||||
expect(eventTypes).toContain("message_end");
|
||||
expect(eventTypes).toContain("turn_end");
|
||||
expect(eventTypes).toContain("agent_end");
|
||||
|
||||
// Should NOT have user message events (that's the difference from agentLoop)
|
||||
const messageEndEvents = events.filter((e) => e.type === "message_end");
|
||||
expect(messageEndEvents.length).toBe(1); // Only assistant message
|
||||
expect((messageEndEvents[0] as any).message.role).toBe("assistant");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from tool result", () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
|
||||
it("should continue processing after tool results", { retry: 3 }, async () => {
|
||||
// Simulate a conversation where:
|
||||
// 1. User asked to calculate something
|
||||
// 2. Assistant made a tool call
|
||||
// 3. Tool result is ready
|
||||
// 4. We continue from here
|
||||
|
||||
const userMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What is 5 + 3? Use the calculator." }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const assistantMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me calculate that for you." },
|
||||
{ type: "toolCall", id: "calc-1", name: "calculate", arguments: { expression: "5 + 3" } },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-4-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const toolResult: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: "calc-1",
|
||||
toolName: "calculate",
|
||||
content: [{ type: "text", text: "5 + 3 = 8" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are a helpful assistant. After getting a calculation result, state the answer clearly.",
|
||||
messages: [userMessage, assistantMessage, toolResult],
|
||||
tools: [calculateTool],
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = { model };
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
const stream = agentLoopContinue(context, config);
|
||||
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const messages = await stream.result();
|
||||
|
||||
// Should have gotten an assistant response
|
||||
expect(messages.length).toBeGreaterThanOrEqual(1);
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(lastMessage.role).toBe("assistant");
|
||||
|
||||
// The assistant should mention the result (8)
|
||||
if (lastMessage.role === "assistant") {
|
||||
const textContent = lastMessage.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => (c as any).text)
|
||||
.join(" ");
|
||||
expect(textContent).toMatch(/8/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -556,7 +556,7 @@ describe("Generate E2E Tests", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5-air via Anthropic Messages)", () => {
|
||||
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5-air via OpenAI Completions)", () => {
|
||||
const llm = getModel("zai", "glm-4.5-air");
|
||||
|
||||
it("should complete basic text generation", { retry: 3 }, async () => {
|
||||
|
|
@ -572,11 +572,11 @@ describe("Generate E2E Tests", () => {
|
|||
});
|
||||
|
||||
it.skip("should handle thinking mode", { retry: 3 }, async () => {
|
||||
await handleThinking(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
|
||||
await handleThinking(llm, { reasoningEffort: "medium" });
|
||||
});
|
||||
|
||||
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
|
||||
await multiTurn(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
|
||||
await multiTurn(llm, { reasoningEffort: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -752,13 +752,11 @@ describe("Generate E2E Tests", () => {
|
|||
const llm = getModel("google-gemini-cli", "gemini-3-flash-preview");
|
||||
|
||||
it.skipIf(!geminiCliToken)("should handle thinking with thinkingLevel", { retry: 3 }, async () => {
|
||||
const { ThinkingLevel } = await import("@google/genai");
|
||||
await handleThinking(llm, { apiKey: geminiCliToken, thinking: { enabled: true, level: ThinkingLevel.LOW } });
|
||||
await handleThinking(llm, { apiKey: geminiCliToken, thinking: { enabled: true, level: "LOW" } });
|
||||
});
|
||||
|
||||
it.skipIf(!geminiCliToken)("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
|
||||
const { ThinkingLevel } = await import("@google/genai");
|
||||
await multiTurn(llm, { apiKey: geminiCliToken, thinking: { enabled: true, level: ThinkingLevel.MEDIUM } });
|
||||
await multiTurn(llm, { apiKey: geminiCliToken, thinking: { enabled: true, level: "MEDIUM" } });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -778,17 +776,15 @@ describe("Generate E2E Tests", () => {
|
|||
});
|
||||
|
||||
it.skipIf(!antigravityToken)("should handle thinking with thinkingLevel", { retry: 3 }, async () => {
|
||||
const { ThinkingLevel } = await import("@google/genai");
|
||||
// gemini-3-flash supports all four levels: MINIMAL, LOW, MEDIUM, HIGH
|
||||
await handleThinking(llm, {
|
||||
apiKey: antigravityToken,
|
||||
thinking: { enabled: true, level: ThinkingLevel.LOW },
|
||||
thinking: { enabled: true, level: "LOW" },
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(!antigravityToken)("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
|
||||
const { ThinkingLevel } = await import("@google/genai");
|
||||
await multiTurn(llm, { apiKey: antigravityToken, thinking: { enabled: true, level: ThinkingLevel.MEDIUM } });
|
||||
await multiTurn(llm, { apiKey: antigravityToken, thinking: { enabled: true, level: "MEDIUM" } });
|
||||
});
|
||||
|
||||
it.skipIf(!antigravityToken)("should handle image input", { retry: 3 }, async () => {
|
||||
|
|
@ -800,11 +796,10 @@ describe("Generate E2E Tests", () => {
|
|||
const llm = getModel("google-antigravity", "gemini-3-pro-high");
|
||||
|
||||
it.skipIf(!antigravityToken)("should handle thinking with thinkingLevel HIGH", { retry: 3 }, async () => {
|
||||
const { ThinkingLevel } = await import("@google/genai");
|
||||
// gemini-3-pro only supports LOW/HIGH
|
||||
await handleThinking(llm, {
|
||||
apiKey: antigravityToken,
|
||||
thinking: { enabled: true, level: ThinkingLevel.HIGH },
|
||||
thinking: { enabled: true, level: "HIGH" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import AjvModule from "ajv";
|
||||
import addFormatsModule from "ajv-formats";
|
||||
|
||||
// Handle both default and named exports
|
||||
const Ajv = (AjvModule as any).default || AjvModule;
|
||||
const addFormats = (addFormatsModule as any).default || addFormatsModule;
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentTool } from "../src/agent/types.js";
|
||||
|
||||
describe("Tool Validation with TypeBox and AJV", () => {
|
||||
// Define a test tool with TypeBox schema
|
||||
const testSchema = Type.Object({
|
||||
name: Type.String({ minLength: 1 }),
|
||||
age: Type.Integer({ minimum: 0, maximum: 150 }),
|
||||
email: Type.String({ format: "email" }),
|
||||
tags: Type.Optional(Type.Array(Type.String())),
|
||||
});
|
||||
|
||||
type TestParams = Static<typeof testSchema>;
|
||||
|
||||
const testTool: AgentTool<typeof testSchema, void> = {
|
||||
label: "Test Tool",
|
||||
name: "test_tool",
|
||||
description: "A test tool for validation",
|
||||
parameters: testSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
return {
|
||||
content: [{ type: "text", text: `Processed: ${args.name}, ${args.age}, ${args.email}` }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Create AJV instance for validation
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
addFormats(ajv);
|
||||
|
||||
it("should validate correct input", () => {
|
||||
const validInput = {
|
||||
name: "John Doe",
|
||||
age: 30,
|
||||
email: "john@example.com",
|
||||
tags: ["developer", "typescript"],
|
||||
};
|
||||
|
||||
// Validate with AJV
|
||||
const validate = ajv.compile(testTool.parameters);
|
||||
const isValid = validate(validInput);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid email", () => {
|
||||
const invalidInput = {
|
||||
name: "John Doe",
|
||||
age: 30,
|
||||
email: "not-an-email",
|
||||
};
|
||||
|
||||
const validate = ajv.compile(testTool.parameters);
|
||||
const isValid = validate(invalidInput);
|
||||
expect(isValid).toBe(false);
|
||||
expect(validate.errors).toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject missing required fields", () => {
|
||||
const invalidInput = {
|
||||
age: 30,
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
const validate = ajv.compile(testTool.parameters);
|
||||
const isValid = validate(invalidInput);
|
||||
expect(isValid).toBe(false);
|
||||
expect(validate.errors).toBeDefined();
|
||||
});
|
||||
|
||||
it("should reject invalid age", () => {
|
||||
const invalidInput = {
|
||||
name: "John Doe",
|
||||
age: -5,
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
const validate = ajv.compile(testTool.parameters);
|
||||
const isValid = validate(invalidInput);
|
||||
expect(isValid).toBe(false);
|
||||
expect(validate.errors).toBeDefined();
|
||||
});
|
||||
|
||||
it("should format validation errors nicely", () => {
|
||||
const invalidInput = {
|
||||
name: "",
|
||||
age: 200,
|
||||
email: "invalid",
|
||||
};
|
||||
|
||||
const validate = ajv.compile(testTool.parameters);
|
||||
const isValid = validate(invalidInput);
|
||||
expect(isValid).toBe(false);
|
||||
expect(validate.errors).toBeDefined();
|
||||
|
||||
if (validate.errors) {
|
||||
const errors = validate.errors
|
||||
.map((err: any) => {
|
||||
const path = err.instancePath ? err.instancePath.substring(1) : err.params.missingProperty || "root";
|
||||
return ` - ${path}: ${err.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// AJV error messages are different from Zod
|
||||
expect(errors).toContain("name: must NOT have fewer than 1 characters");
|
||||
expect(errors).toContain("age: must be <= 150");
|
||||
expect(errors).toContain('email: must match format "email"');
|
||||
}
|
||||
});
|
||||
|
||||
it("should have type-safe execute function", async () => {
|
||||
const validInput = {
|
||||
name: "John Doe",
|
||||
age: 30,
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
// Validate and execute
|
||||
const validate = ajv.compile(testTool.parameters);
|
||||
const isValid = validate(validInput);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
const result = await testTool.execute("test-id", validInput as TestParams);
|
||||
|
||||
const textOutput = result.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n");
|
||||
expect(textOutput).toBe("Processed: John Doe, 30, john@example.com");
|
||||
expect(result.details).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,13 +2,213 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
This release introduces session trees for in-place branching, major API changes to hooks and custom tools, and structured compaction with file tracking.
|
||||
|
||||
### Session Tree
|
||||
|
||||
Sessions now use a tree structure with `id`/`parentId` fields. This enables in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file.
|
||||
|
||||
**Existing sessions are automatically migrated** (v1 → v2) on first load. No manual action required.
|
||||
|
||||
New entry types: `BranchSummaryEntry` (context from abandoned branches), `CustomEntry` (hook state), `CustomMessageEntry` (hook-injected messages), `LabelEntry` (bookmarks).
|
||||
|
||||
See [docs/session.md](docs/session.md) for the file format and `SessionManager` API.
|
||||
|
||||
### Hooks Migration
|
||||
|
||||
The hooks API has been restructured with more granular events and better session access.
|
||||
|
||||
**Type renames:**
|
||||
- `HookEventContext` → `HookContext`
|
||||
- `HookCommandContext` removed (use `HookContext` for command handlers)
|
||||
|
||||
**Event changes:**
|
||||
- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_new`, `session_new`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_shutdown`
|
||||
- New `session_before_tree` and `session_tree` events for `/tree` navigation (hook can provide custom branch summary)
|
||||
- New `before_agent_start` event: inject messages before the agent loop starts
|
||||
- New `context` event: modify messages non-destructively before each LLM call
|
||||
- Session entries are no longer passed in events. Use `ctx.sessionManager.getEntries()` or `ctx.sessionManager.getBranch()` instead
|
||||
|
||||
**API changes:**
|
||||
- `pi.send(text, attachments?)` → `pi.sendMessage(message, triggerTurn?)` (creates `CustomMessageEntry`)
|
||||
- New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context)
|
||||
- New `pi.registerCommand(name, options)` for custom slash commands
|
||||
- New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering
|
||||
- New `ctx.ui.custom(component)` for full TUI component rendering with keyboard focus
|
||||
- `ctx.exec()` moved to `pi.exec()`
|
||||
- `ctx.sessionFile` → `ctx.sessionManager.getSessionFile()`
|
||||
- New `ctx.modelRegistry` and `ctx.model` for API key resolution
|
||||
|
||||
**Removed:**
|
||||
- `hookTimeout` setting (hooks no longer have timeouts; use Ctrl+C to abort)
|
||||
- `resolveApiKey` parameter (use `ctx.modelRegistry.getApiKey(model)`)
|
||||
|
||||
See [docs/hooks.md](docs/hooks.md) and [examples/hooks/](examples/hooks/) for the current API.
|
||||
|
||||
### Custom Tools Migration
|
||||
|
||||
The custom tools API has been restructured to mirror the hooks pattern with a context object.
|
||||
|
||||
**Type renames:**
|
||||
- `CustomAgentTool` → `CustomTool`
|
||||
- `ToolAPI` → `CustomToolAPI`
|
||||
- `ToolContext` → `CustomToolContext`
|
||||
- `ToolSessionEvent` → `CustomToolSessionEvent`
|
||||
|
||||
**Execute signature changed:**
|
||||
```typescript
|
||||
// Before (v0.30.2)
|
||||
execute(toolCallId, params, signal, onUpdate)
|
||||
|
||||
// After
|
||||
execute(toolCallId, params, onUpdate, ctx, signal?)
|
||||
```
|
||||
|
||||
The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, and `model`.
|
||||
|
||||
**Session event changes:**
|
||||
- `CustomToolSessionEvent` now only has `reason` and `previousSessionFile`
|
||||
- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` or `ctx.sessionManager.getEntries()` to reconstruct state
|
||||
- New reasons: `"tree"` (for `/tree` navigation) and `"shutdown"` (for cleanup on exit)
|
||||
- `dispose()` method removed. Use `onSession` with `reason: "shutdown"` for cleanup
|
||||
|
||||
See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/) for the current API.
|
||||
|
||||
### SDK Migration
|
||||
|
||||
**Type changes:**
|
||||
- `CustomAgentTool` → `CustomTool`
|
||||
- `AppMessage` → `AgentMessage`
|
||||
- `sessionFile` returns `string | undefined` (was `string | null`)
|
||||
- `model` returns `Model | undefined` (was `Model | null`)
|
||||
- `Attachment` type removed. Use `ImageContent` from `@mariozechner/pi-ai` instead. Add images directly to message content arrays.
|
||||
|
||||
**AgentSession branching API:**
|
||||
- `branch(entryIndex: number)` → `branch(entryId: string)`
|
||||
- `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }`
|
||||
- `reset()` and `switchSession()` now return `Promise<boolean>` (false if cancelled by hook)
|
||||
- New `navigateTree(targetId, options?)` for in-place tree navigation
|
||||
|
||||
**Hook integration:**
|
||||
- New `sendHookMessage(message, triggerTurn?)` for hook message injection
|
||||
|
||||
**SessionManager API:**
|
||||
- Method renames: `saveXXX()` → `appendXXX()` (e.g., `appendMessage`, `appendCompaction`)
|
||||
- `branchInPlace()` → `branch()`
|
||||
- `reset()` → `newSession()`
|
||||
- `createBranchedSessionFromEntries(entries, index)` → `createBranchedSession(leafId)`
|
||||
- `saveCompaction(entry)` → `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?)`
|
||||
- `getEntries()` now excludes the session header (use `getHeader()` separately)
|
||||
- `getSessionFile()` returns `string | undefined` (undefined for in-memory sessions)
|
||||
- New tree methods: `getTree()`, `getBranch()`, `getLeafId()`, `getLeafEntry()`, `getEntry()`, `getChildren()`, `getLabel()`
|
||||
- New append methods: `appendCustomEntry()`, `appendCustomMessageEntry()`, `appendLabelChange()`
|
||||
- New branch methods: `branch(entryId)`, `branchWithSummary()`
|
||||
|
||||
**ModelRegistry (new):**
|
||||
|
||||
`ModelRegistry` is a new class that manages model discovery and API key resolution. It combines built-in models with custom models from `models.json` and resolves API keys via `AuthStorage`.
|
||||
|
||||
```typescript
|
||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
|
||||
const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
|
||||
|
||||
// Get all models (built-in + custom)
|
||||
const allModels = modelRegistry.getAll();
|
||||
|
||||
// Get only models with valid API keys
|
||||
const available = await modelRegistry.getAvailable();
|
||||
|
||||
// Find specific model
|
||||
const model = modelRegistry.find("anthropic", "claude-sonnet-4-20250514");
|
||||
|
||||
// Get API key for a model
|
||||
const apiKey = await modelRegistry.getApiKey(model);
|
||||
```
|
||||
|
||||
This replaces the old `resolveApiKey` callback pattern. Hooks and custom tools access it via `ctx.modelRegistry`.
|
||||
|
||||
**Renamed exports:**
|
||||
- `messageTransformer` → `convertToLlm`
|
||||
- `SessionContext` alias `LoadedSession` removed
|
||||
|
||||
See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/) for the current API.
|
||||
|
||||
### RPC Migration
|
||||
|
||||
**Branching commands:**
|
||||
- `branch` command: `entryIndex` → `entryId`
|
||||
- `get_branch_messages` response: `entryIndex` → `entryId`
|
||||
|
||||
**Type changes:**
|
||||
- Messages are now `AgentMessage` (was `AppMessage`)
|
||||
- `prompt` command: `attachments` field replaced with `images` field using `ImageContent` format
|
||||
|
||||
**Compaction events:**
|
||||
- `auto_compaction_start` now includes `reason` field (`"threshold"` or `"overflow"`)
|
||||
- `auto_compaction_end` now includes `willRetry` field
|
||||
- `compact` response includes full `CompactionResult` (`summary`, `firstKeptEntryId`, `tokensBefore`, `details`)
|
||||
|
||||
See [docs/rpc.md](docs/rpc.md) for the current protocol.
|
||||
|
||||
### Structured Compaction
|
||||
|
||||
Compaction and branch summarization now use a structured output format:
|
||||
- Clear sections: Goal, Progress, Key Information, File Operations
|
||||
- File tracking: `readFiles` and `modifiedFiles` arrays in `details`, accumulated across compactions
|
||||
- Conversations are serialized to text before summarization to prevent the model from "continuing" them
|
||||
|
||||
The `before_compact` and `before_tree` hook events allow custom compaction implementations. See [docs/compaction.md](docs/compaction.md).
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
**`/tree` command:**
|
||||
- Navigate the full session tree in-place
|
||||
- Search by typing, page with ←/→
|
||||
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
|
||||
- Press `l` to label entries as bookmarks
|
||||
- Selecting a branch switches context and optionally injects a summary of the abandoned branch
|
||||
|
||||
**Entry labels:**
|
||||
- Bookmark any entry via `/tree` → select → `l`
|
||||
- Labels appear in tree view and persist as `LabelEntry`
|
||||
|
||||
**Theme changes (breaking for custom themes):**
|
||||
|
||||
Custom themes must add these new color tokens or they will fail to load:
|
||||
- `selectedBg`: background for selected/highlighted items in tree selector and other components
|
||||
- `customMessageBg`: background for hook-injected messages (`CustomMessageEntry`)
|
||||
- `customMessageText`: text color for hook messages
|
||||
- `customMessageLabel`: label color for hook messages (the `[customType]` prefix)
|
||||
|
||||
Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) for the full color list and copy values from the built-in dark/light themes.
|
||||
|
||||
**Settings:**
|
||||
- `enabledModels`: allowlist models in `settings.json` (same format as `--models` CLI)
|
||||
|
||||
### Added
|
||||
|
||||
- **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting.
|
||||
- **Snake game example hook**: Demonstrates `ui.custom()`, `registerCommand()`, and session persistence. See [examples/hooks/snake.ts](examples/hooks/snake.ts).
|
||||
- **`thinkingText` theme token**: Configurable color for thinking block text. ([#366](https://github.com/badlogic/pi-mono/pull/366) by [@paulbettner](https://github.com/paulbettner))
|
||||
|
||||
### Changed
|
||||
|
||||
- **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs
|
||||
- **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355))
|
||||
- **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed.
|
||||
- **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou))
|
||||
- **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon))
|
||||
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey))
|
||||
- **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri))
|
||||
- **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez))
|
||||
- **Improved error messages**: Better error messages when `apiKey` or `model` are missing. ([#346](https://github.com/badlogic/pi-mono/pull/346) by [@ronyrus](https://github.com/ronyrus))
|
||||
- **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded
|
||||
- **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings
|
||||
- **Compaction with branched sessions**: Fixed compaction incorrectly including entries from abandoned branches, causing token overflow errors. Compaction now uses `sessionManager.getPath()` to work only on the current branch path, eliminating 80+ lines of duplicate entry collection logic between `prepareCompaction()` and `compact()`
|
||||
|
||||
## [0.30.2] - 2025-12-26
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
|
|||
- [Project Context Files](#project-context-files)
|
||||
- [Custom System Prompt](#custom-system-prompt)
|
||||
- [Custom Models and Providers](#custom-models-and-providers)
|
||||
- [Settings File](#settings-file)
|
||||
- [Extensions](#extensions)
|
||||
- [Themes](#themes)
|
||||
- [Custom Slash Commands](#custom-slash-commands)
|
||||
- [Skills](#skills)
|
||||
- [Hooks](#hooks)
|
||||
- [Custom Tools](#custom-tools)
|
||||
- [Settings File](#settings-file)
|
||||
- [CLI Reference](#cli-reference)
|
||||
- [Tools](#tools)
|
||||
- [Programmatic Usage](#programmatic-usage)
|
||||
|
|
@ -193,6 +194,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
|
|||
| `/session` | Show session info: path, message counts, token usage, cost |
|
||||
| `/hotkeys` | Show all keyboard shortcuts |
|
||||
| `/changelog` | Display full version history |
|
||||
| `/tree` | Navigate session tree in-place (search, filter, label entries) |
|
||||
| `/branch` | Create new conversation branch from a previous message |
|
||||
| `/resume` | Switch to a different session (interactive selector) |
|
||||
| `/login` | OAuth login for subscription-based models |
|
||||
|
|
@ -291,6 +293,10 @@ Toggle inline images via `/settings` or set `terminal.showImages: false` in sett
|
|||
|
||||
## Sessions
|
||||
|
||||
Sessions are stored as JSONL files with a **tree structure**. Each entry has an `id` and `parentId`, enabling in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file.
|
||||
|
||||
See [docs/session.md](docs/session.md) for the file format and programmatic API.
|
||||
|
||||
### Session Management
|
||||
|
||||
Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory.
|
||||
|
|
@ -319,14 +325,6 @@ Long sessions can exhaust context windows. Compaction summarizes older messages
|
|||
|
||||
When disabled, neither case triggers automatic compaction (use `/compact` manually if needed).
|
||||
|
||||
**How it works:**
|
||||
1. Cut point calculated to keep ~20k tokens of recent messages
|
||||
2. Messages before cut point are summarized
|
||||
3. Summary replaces old messages as "context handoff"
|
||||
4. Previous compaction summaries chain into new ones
|
||||
|
||||
Compaction does not create a new session, but continues the existing one, with a marker in the `.jsonl` file that encodes the compaction point.
|
||||
|
||||
**Configuration** (`~/.pi/agent/settings.json`):
|
||||
|
||||
```json
|
||||
|
|
@ -339,11 +337,20 @@ Compaction does not create a new session, but continues the existing one, with a
|
|||
}
|
||||
```
|
||||
|
||||
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/branch` to revisit any previous point.
|
||||
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point.
|
||||
|
||||
See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via hooks.
|
||||
|
||||
### Branching
|
||||
|
||||
Use `/branch` to explore alternative conversation paths:
|
||||
**In-place navigation (`/tree`):** Navigate the session tree without creating new files. Select any previous point, continue from there, and switch between branches while preserving all history.
|
||||
|
||||
- Search by typing, page with ←/→
|
||||
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
|
||||
- Press `l` to label entries as bookmarks
|
||||
- When switching branches, you're prompted whether to generate a summary of the abandoned branch (messages up to the common ancestor)
|
||||
|
||||
**Create new session (`/branch`):** Branch to a new session file:
|
||||
|
||||
1. Opens selector showing all your user messages
|
||||
2. Select a message to branch from
|
||||
|
|
@ -473,6 +480,75 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`:
|
|||
|
||||
> pi can help you create custom provider and model configurations.
|
||||
|
||||
### Settings File
|
||||
|
||||
Settings are loaded from two locations and merged:
|
||||
|
||||
1. **Global:** `~/.pi/agent/settings.json` - user preferences
|
||||
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
|
||||
|
||||
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
|
||||
|
||||
Global `~/.pi/agent/settings.json` stores persistent preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "dark",
|
||||
"defaultProvider": "anthropic",
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"defaultThinkingLevel": "medium",
|
||||
"enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"],
|
||||
"queueMode": "one-at-a-time",
|
||||
"shellPath": "C:\\path\\to\\bash.exe",
|
||||
"hideThinkingBlock": false,
|
||||
"collapseChangelog": false,
|
||||
"compaction": {
|
||||
"enabled": true,
|
||||
"reserveTokens": 16384,
|
||||
"keepRecentTokens": 20000
|
||||
},
|
||||
"skills": {
|
||||
"enabled": true
|
||||
},
|
||||
"retry": {
|
||||
"enabled": true,
|
||||
"maxRetries": 3,
|
||||
"baseDelayMs": 2000
|
||||
},
|
||||
"terminal": {
|
||||
"showImages": true
|
||||
},
|
||||
"hooks": ["/path/to/hook.ts"],
|
||||
"customTools": ["/path/to/tool.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `theme` | Color theme name | auto-detected |
|
||||
| `defaultProvider` | Default model provider | - |
|
||||
| `defaultModel` | Default model ID | - |
|
||||
| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
|
||||
| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - |
|
||||
| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
|
||||
| `shellPath` | Custom bash path (Windows) | auto-detected |
|
||||
| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
|
||||
| `collapseChangelog` | Show condensed changelog after update | `false` |
|
||||
| `compaction.enabled` | Enable auto-compaction | `true` |
|
||||
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
|
||||
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
|
||||
| `skills.enabled` | Enable skills discovery | `true` |
|
||||
| `retry.enabled` | Auto-retry on transient errors | `true` |
|
||||
| `retry.maxRetries` | Maximum retry attempts | `3` |
|
||||
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
|
||||
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
|
||||
| `hooks` | Additional hook file paths | `[]` |
|
||||
| `customTools` | Additional custom tool file paths | `[]` |
|
||||
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
### Themes
|
||||
|
||||
Built-in themes: `dark` (default), `light`. Auto-detected on first run.
|
||||
|
|
@ -612,18 +688,23 @@ export default function (pi: HookAPI) {
|
|||
|
||||
**Sending messages from hooks:**
|
||||
|
||||
Use `pi.send(text, attachments?)` to inject messages into the session. If the agent is streaming, the message is queued; otherwise a new agent loop starts immediately.
|
||||
Use `pi.sendMessage(message, triggerTurn?)` to inject messages into the session. Messages are persisted as `CustomMessageEntry` and sent to the LLM. If the agent is streaming, the message is queued; otherwise a new agent loop starts if `triggerTurn` is true.
|
||||
|
||||
```typescript
|
||||
import * as fs from "node:fs";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event) => {
|
||||
if (event.reason !== "start") return;
|
||||
pi.on("session_start", async () => {
|
||||
fs.watch("/tmp/trigger.txt", () => {
|
||||
const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
|
||||
if (content) pi.send(content);
|
||||
if (content) {
|
||||
pi.sendMessage({
|
||||
customType: "file-trigger",
|
||||
content,
|
||||
display: true,
|
||||
}, true); // triggerTurn: start agent loop
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -659,10 +740,11 @@ const factory: CustomToolFactory = (pi) => ({
|
|||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
||||
details: { greeted: params.name },
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -682,73 +764,6 @@ export default factory;
|
|||
|
||||
> See [examples/custom-tools/](examples/custom-tools/) for working examples including a todo list with session state management and a question tool with UI interaction.
|
||||
|
||||
### Settings File
|
||||
|
||||
Settings are loaded from two locations and merged:
|
||||
|
||||
1. **Global:** `~/.pi/agent/settings.json` - user preferences
|
||||
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
|
||||
|
||||
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
|
||||
|
||||
Global `~/.pi/agent/settings.json` stores persistent preferences:
|
||||
|
||||
```json
|
||||
{
|
||||
"theme": "dark",
|
||||
"defaultProvider": "anthropic",
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"defaultThinkingLevel": "medium",
|
||||
"enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"],
|
||||
"queueMode": "one-at-a-time",
|
||||
"shellPath": "C:\\path\\to\\bash.exe",
|
||||
"hideThinkingBlock": false,
|
||||
"collapseChangelog": false,
|
||||
"compaction": {
|
||||
"enabled": true,
|
||||
"reserveTokens": 16384,
|
||||
"keepRecentTokens": 20000
|
||||
},
|
||||
"skills": {
|
||||
"enabled": true
|
||||
},
|
||||
"retry": {
|
||||
"enabled": true,
|
||||
"maxRetries": 3,
|
||||
"baseDelayMs": 2000
|
||||
},
|
||||
"terminal": {
|
||||
"showImages": true
|
||||
},
|
||||
"hooks": ["/path/to/hook.ts"],
|
||||
"hookTimeout": 30000,
|
||||
"customTools": ["/path/to/tool.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| `theme` | Color theme name | auto-detected |
|
||||
| `defaultProvider` | Default model provider | - |
|
||||
| `defaultModel` | Default model ID | - |
|
||||
| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
|
||||
| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - |
|
||||
| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
|
||||
| `shellPath` | Custom bash path (Windows) | auto-detected |
|
||||
| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
|
||||
| `collapseChangelog` | Show condensed changelog after update | `false` |
|
||||
| `compaction.enabled` | Enable auto-compaction | `true` |
|
||||
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
|
||||
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
|
||||
| `skills.enabled` | Enable skills discovery | `true` |
|
||||
| `retry.enabled` | Auto-retry on transient errors | `true` |
|
||||
| `retry.maxRetries` | Maximum retry attempts | `3` |
|
||||
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
|
||||
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
|
||||
| `hooks` | Additional hook file paths | `[]` |
|
||||
| `hookTimeout` | Timeout for hook operations (ms) | `30000` |
|
||||
| `customTools` | Additional custom tool file paths | `[]` |
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
|
|
|||
388
packages/coding-agent/docs/compaction.md
Normal file
388
packages/coding-agent/docs/compaction.md
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# Compaction & Branch Summarization
|
||||
|
||||
LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.
|
||||
|
||||
**Source files:**
|
||||
- [`src/core/compaction/compaction.ts`](../src/core/compaction/compaction.ts) - Auto-compaction logic
|
||||
- [`src/core/compaction/branch-summarization.ts`](../src/core/compaction/branch-summarization.ts) - Branch summarization
|
||||
- [`src/core/compaction/utils.ts`](../src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization)
|
||||
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`)
|
||||
- [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) - Hook event types
|
||||
|
||||
## Overview
|
||||
|
||||
Pi has two summarization mechanisms:
|
||||
|
||||
| Mechanism | Trigger | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
|
||||
| Branch summarization | `/tree` navigation | Preserve context when switching branches |
|
||||
|
||||
Both use the same structured summary format and track file operations cumulatively.
|
||||
|
||||
## Compaction
|
||||
|
||||
### When It Triggers
|
||||
|
||||
Auto-compaction triggers when:
|
||||
|
||||
```
|
||||
contextTokens > contextWindow - reserveTokens
|
||||
```
|
||||
|
||||
By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`). This leaves room for the LLM's response.
|
||||
|
||||
You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`) is reached
|
||||
2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point
|
||||
3. **Generate summary**: Call LLM to summarize with structured format
|
||||
4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`
|
||||
5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards
|
||||
|
||||
```
|
||||
Before compaction:
|
||||
|
||||
entry: 0 1 2 3 4 5 6 7 8 9
|
||||
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
|
||||
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
|
||||
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
|
||||
└────────┬───────┘ └──────────────┬──────────────┘
|
||||
messagesToSummarize kept messages
|
||||
↑
|
||||
firstKeptEntryId (entry 4)
|
||||
|
||||
After compaction (new entry appended):
|
||||
|
||||
entry: 0 1 2 3 4 5 6 7 8 9 10
|
||||
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
|
||||
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
|
||||
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
|
||||
└──────────┬──────┘ └──────────────────────┬───────────────────┘
|
||||
not sent to LLM sent to LLM
|
||||
↑
|
||||
starts from firstKeptEntryId
|
||||
|
||||
What the LLM sees:
|
||||
|
||||
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
|
||||
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
|
||||
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
|
||||
↑ ↑ └─────────────────┬────────────────┘
|
||||
prompt from cmp messages from firstKeptEntryId
|
||||
```
|
||||
|
||||
### Split Turns
|
||||
|
||||
A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.
|
||||
|
||||
When a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a "split turn":
|
||||
|
||||
```
|
||||
Split turn (one huge turn exceeds budget):
|
||||
|
||||
entry: 0 1 2 3 4 5 6 7 8
|
||||
┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
|
||||
│ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
|
||||
└─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
|
||||
↑ ↑
|
||||
turnStartIndex = 1 firstKeptEntryId = 7
|
||||
│ │
|
||||
└──── turnPrefixMessages (1-6) ───────┘
|
||||
└── kept (7-8)
|
||||
|
||||
isSplitTurn = true
|
||||
messagesToSummarize = [] (no complete turns before)
|
||||
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
|
||||
```
|
||||
|
||||
For split turns, pi generates two summaries and merges them:
|
||||
1. **History summary**: Previous context (if any)
|
||||
2. **Turn prefix summary**: The early part of the split turn
|
||||
|
||||
### Cut Point Rules
|
||||
|
||||
Valid cut points are:
|
||||
- User messages
|
||||
- Assistant messages
|
||||
- BashExecution messages
|
||||
- Hook messages (custom_message, branch_summary)
|
||||
|
||||
Never cut at tool results (they must stay with their tool call).
|
||||
|
||||
### CompactionEntry Structure
|
||||
|
||||
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
|
||||
|
||||
```typescript
|
||||
interface CompactionEntry<T = unknown> {
|
||||
type: "compaction";
|
||||
id: string;
|
||||
parentId: string;
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
fromHook?: boolean; // true if hook provided the compaction
|
||||
details?: T; // hook-specific data
|
||||
}
|
||||
|
||||
// Default compaction uses this for details (from compaction.ts):
|
||||
interface CompactionDetails {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
```
|
||||
|
||||
Hooks can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom compaction hooks can use their own structure.
|
||||
|
||||
See [`prepareCompaction()`](../src/core/compaction/compaction.ts) and [`compact()`](../src/core/compaction/compaction.ts) for the implementation.
|
||||
|
||||
## Branch Summarization
|
||||
|
||||
### When It Triggers
|
||||
|
||||
When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Find common ancestor**: Deepest node shared by old and new positions
|
||||
2. **Collect entries**: Walk from old leaf back to common ancestor
|
||||
3. **Prepare with budget**: Include messages up to token budget (newest first)
|
||||
4. **Generate summary**: Call LLM with structured format
|
||||
5. **Append entry**: Save `BranchSummaryEntry` at navigation point
|
||||
|
||||
```
|
||||
Tree before navigation:
|
||||
|
||||
┌─ B ─ C ─ D (old leaf, being abandoned)
|
||||
A ───┤
|
||||
└─ E ─ F (target)
|
||||
|
||||
Common ancestor: A
|
||||
Entries to summarize: B, C, D
|
||||
|
||||
After navigation with summary:
|
||||
|
||||
┌─ B ─ C ─ D ─ [summary of B,C,D]
|
||||
A ───┤
|
||||
└─ E ─ F (new leaf)
|
||||
```
|
||||
|
||||
### Cumulative File Tracking
|
||||
|
||||
Both compaction and branch summarization track files cumulatively. When generating a summary, pi extracts file operations from:
|
||||
- Tool calls in the messages being summarized
|
||||
- Previous compaction or branch summary `details` (if any)
|
||||
|
||||
This means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files.
|
||||
|
||||
### BranchSummaryEntry Structure
|
||||
|
||||
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
|
||||
|
||||
```typescript
|
||||
interface BranchSummaryEntry<T = unknown> {
|
||||
type: "branch_summary";
|
||||
id: string;
|
||||
parentId: string;
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fromId: string; // Entry we navigated from
|
||||
fromHook?: boolean; // true if hook provided the summary
|
||||
details?: T; // hook-specific data
|
||||
}
|
||||
|
||||
// Default branch summarization uses this for details (from branch-summarization.ts):
|
||||
interface BranchSummaryDetails {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
```
|
||||
|
||||
Same as compaction, hooks can store custom data in `details`.
|
||||
|
||||
See [`collectEntriesForBranchSummary()`](../src/core/compaction/branch-summarization.ts), [`prepareBranchEntries()`](../src/core/compaction/branch-summarization.ts), and [`generateBranchSummary()`](../src/core/compaction/branch-summarization.ts) for the implementation.
|
||||
|
||||
## Summary Format
|
||||
|
||||
Both compaction and branch summarization use the same structured format:
|
||||
|
||||
```markdown
|
||||
## Goal
|
||||
[What the user is trying to accomplish]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Requirements mentioned by user]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work]
|
||||
|
||||
### Blocked
|
||||
- [Issues, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [What should happen next]
|
||||
|
||||
## Critical Context
|
||||
- [Data needed to continue]
|
||||
|
||||
<read-files>
|
||||
path/to/file1.ts
|
||||
path/to/file2.ts
|
||||
</read-files>
|
||||
|
||||
<modified-files>
|
||||
path/to/changed.ts
|
||||
</modified-files>
|
||||
```
|
||||
|
||||
### Message Serialization
|
||||
|
||||
Before summarization, messages are serialized to text via [`serializeConversation()`](../src/core/compaction/utils.ts):
|
||||
|
||||
```
|
||||
[User]: What they said
|
||||
[Assistant thinking]: Internal reasoning
|
||||
[Assistant]: Response text
|
||||
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
|
||||
[Tool result]: Output from tool
|
||||
```
|
||||
|
||||
This prevents the model from treating it as a conversation to continue.
|
||||
|
||||
## Custom Summarization via Hooks
|
||||
|
||||
Hooks can intercept and customize both compaction and branch summarization. See [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) for event type definitions.
|
||||
|
||||
### session_before_compact
|
||||
|
||||
Fired before auto-compaction or `/compact`. Can cancel or provide custom summary. See `SessionBeforeCompactEvent` and `CompactionPreparation` in the types file.
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, branchEntries, customInstructions, signal } = event;
|
||||
|
||||
// preparation.messagesToSummarize - messages to summarize
|
||||
// preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
|
||||
// preparation.previousSummary - previous compaction summary
|
||||
// preparation.fileOps - extracted file operations
|
||||
// preparation.tokensBefore - context tokens before compaction
|
||||
// preparation.firstKeptEntryId - where kept messages start
|
||||
// preparation.settings - compaction settings
|
||||
|
||||
// branchEntries - all entries on current branch (for custom state)
|
||||
// signal - AbortSignal (pass to LLM calls)
|
||||
|
||||
// Cancel:
|
||||
return { cancel: true };
|
||||
|
||||
// Custom summary:
|
||||
return {
|
||||
compaction: {
|
||||
summary: "Your summary...",
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { /* custom data */ },
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
#### Converting Messages to Text
|
||||
|
||||
To generate a summary with your own model, convert messages to text using `serializeConversation`:
|
||||
|
||||
```typescript
|
||||
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation } = event;
|
||||
|
||||
// Convert AgentMessage[] to Message[], then serialize to text
|
||||
const conversationText = serializeConversation(
|
||||
convertToLlm(preparation.messagesToSummarize)
|
||||
);
|
||||
// Returns:
|
||||
// [User]: message text
|
||||
// [Assistant thinking]: thinking content
|
||||
// [Assistant]: response text
|
||||
// [Assistant tool calls]: read(path="..."); bash(command="...")
|
||||
// [Tool result]: output text
|
||||
|
||||
// Now send to your model for summarization
|
||||
const summary = await myModel.summarize(conversationText);
|
||||
|
||||
return {
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example using a different model.
|
||||
|
||||
### session_before_tree
|
||||
|
||||
Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary.
|
||||
|
||||
```typescript
|
||||
pi.on("session_before_tree", async (event, ctx) => {
|
||||
const { preparation, signal } = event;
|
||||
|
||||
// preparation.targetId - where we're navigating to
|
||||
// preparation.oldLeafId - current position (being abandoned)
|
||||
// preparation.commonAncestorId - shared ancestor
|
||||
// preparation.entriesToSummarize - entries that would be summarized
|
||||
// preparation.userWantsSummary - whether user chose to summarize
|
||||
|
||||
// Cancel navigation entirely:
|
||||
return { cancel: true };
|
||||
|
||||
// Provide custom summary (only used if userWantsSummary is true):
|
||||
if (preparation.userWantsSummary) {
|
||||
return {
|
||||
summary: {
|
||||
summary: "Your summary...",
|
||||
details: { /* custom data */ },
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
See `SessionBeforeTreeEvent` and `TreePreparation` in the types file.
|
||||
|
||||
## Settings
|
||||
|
||||
Configure compaction in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compaction": {
|
||||
"enabled": true,
|
||||
"reserveTokens": 16384,
|
||||
"keepRecentTokens": 20000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `enabled` | `true` | Enable auto-compaction |
|
||||
| `reserveTokens` | `16384` | Tokens to reserve for LLM response |
|
||||
| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |
|
||||
|
||||
Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`.
|
||||
|
|
@ -1,13 +1,21 @@
|
|||
> pi can create custom tools. Ask it to build one for your use case.
|
||||
|
||||
# Custom Tools
|
||||
|
||||
Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.
|
||||
|
||||
**Key capabilities:**
|
||||
- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs)
|
||||
- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult`
|
||||
- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md))
|
||||
- **State management** - Persist state in tool result `details` for proper branching support
|
||||
- **Streaming results** - Send partial updates via `onUpdate` callback
|
||||
|
||||
**Example use cases:**
|
||||
- Ask the user questions with selectable options
|
||||
- Maintain state across calls (todo lists, connection pools)
|
||||
- Custom TUI rendering (progress indicators, structured output)
|
||||
- Integrate external services with proper error handling
|
||||
- Tools that need user confirmation before proceeding
|
||||
- Interactive dialogs (questions with selectable options)
|
||||
- Stateful tools (todo lists, connection pools)
|
||||
- Rich output rendering (progress indicators, structured views)
|
||||
- External service integrations with confirmation flows
|
||||
|
||||
**When to use custom tools vs. alternatives:**
|
||||
|
||||
|
|
@ -36,10 +44,11 @@ const factory: CustomToolFactory = (pi) => ({
|
|||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
||||
details: { greeted: params.name },
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -82,7 +91,7 @@ Custom tools can import from these packages (automatically resolved by pi):
|
|||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) |
|
||||
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `ToolSessionEvent`, etc.) |
|
||||
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `CustomTool`, `CustomToolContext`, etc.) |
|
||||
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
|
||||
| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) |
|
||||
|
||||
|
|
@ -94,7 +103,12 @@ Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
|
||||
import type {
|
||||
CustomTool,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const factory: CustomToolFactory = (pi) => ({
|
||||
name: "my_tool",
|
||||
|
|
@ -106,9 +120,10 @@ const factory: CustomToolFactory = (pi) => ({
|
|||
text: Type.Optional(Type.String()),
|
||||
}),
|
||||
|
||||
async execute(toolCallId, params, signal, onUpdate) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// signal - AbortSignal for cancellation
|
||||
// onUpdate - Callback for streaming partial results
|
||||
// ctx - CustomToolContext with sessionManager, modelRegistry, model
|
||||
return {
|
||||
content: [{ type: "text", text: "Result for LLM" }],
|
||||
details: { /* structured data for rendering */ },
|
||||
|
|
@ -116,14 +131,17 @@ const factory: CustomToolFactory = (pi) => ({
|
|||
},
|
||||
|
||||
// Optional: Session lifecycle callback
|
||||
onSession(event) { /* reconstruct state from entries */ },
|
||||
onSession(event, ctx) {
|
||||
if (event.reason === "shutdown") {
|
||||
// Cleanup resources (close connections, save state, etc.)
|
||||
return;
|
||||
}
|
||||
// Reconstruct state from ctx.sessionManager.getBranch()
|
||||
},
|
||||
|
||||
// Optional: Custom rendering
|
||||
renderCall(args, theme) { /* return Component */ },
|
||||
renderResult(result, options, theme) { /* return Component */ },
|
||||
|
||||
// Optional: Cleanup on session end
|
||||
dispose() { /* save state, close connections */ },
|
||||
});
|
||||
|
||||
export default factory;
|
||||
|
|
@ -131,23 +149,26 @@ export default factory;
|
|||
|
||||
**Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API.
|
||||
|
||||
## ToolAPI Object
|
||||
## CustomToolAPI Object
|
||||
|
||||
The factory receives a `ToolAPI` object (named `pi` by convention):
|
||||
The factory receives a `CustomToolAPI` object (named `pi` by convention):
|
||||
|
||||
```typescript
|
||||
interface ToolAPI {
|
||||
interface CustomToolAPI {
|
||||
cwd: string; // Current working directory
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
ui: {
|
||||
select(title: string, options: string[]): Promise<string | null>;
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
input(title: string, placeholder?: string): Promise<string | null>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
};
|
||||
ui: ToolUIContext;
|
||||
hasUI: boolean; // false in --print or --mode rpc
|
||||
}
|
||||
|
||||
interface ToolUIContext {
|
||||
select(title: string, options: string[]): Promise<string | undefined>;
|
||||
confirm(title: string, message: string): Promise<boolean>;
|
||||
input(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||
custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
|
||||
}
|
||||
|
||||
interface ExecOptions {
|
||||
signal?: AbortSignal; // Cancel the process
|
||||
timeout?: number; // Timeout in milliseconds
|
||||
|
|
@ -168,7 +189,7 @@ Always check `pi.hasUI` before using UI methods.
|
|||
Pass the `signal` from `execute` to `pi.exec` to support cancellation:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, signal) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const result = await pi.exec("long-running-command", ["arg"], { signal });
|
||||
if (result.killed) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
|
|
@ -177,16 +198,51 @@ async execute(toolCallId, params, signal) {
|
|||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Throw an error** when the tool fails. Do not return an error message as content.
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const { path } = params as { path: string };
|
||||
|
||||
// Throw on error - pi will catch it and report to the LLM
|
||||
if (!fs.existsSync(path)) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
// Return content only on success
|
||||
return { content: [{ type: "text", text: "Success" }] };
|
||||
}
|
||||
```
|
||||
|
||||
Thrown errors are:
|
||||
- Reported to the LLM as tool errors (with `isError: true`)
|
||||
- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`)
|
||||
- Displayed in the TUI with error styling
|
||||
|
||||
## CustomToolContext
|
||||
|
||||
The `execute` and `onSession` callbacks receive a `CustomToolContext`:
|
||||
|
||||
```typescript
|
||||
interface CustomToolContext {
|
||||
sessionManager: ReadonlySessionManager; // Read-only access to session
|
||||
modelRegistry: ModelRegistry; // For API key resolution
|
||||
model: Model | undefined; // Current model (may be undefined)
|
||||
}
|
||||
```
|
||||
|
||||
Use `ctx.sessionManager.getBranch()` to get entries on the current branch for state reconstruction.
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
Tools can implement `onSession` to react to session changes:
|
||||
|
||||
```typescript
|
||||
interface ToolSessionEvent {
|
||||
entries: SessionEntry[]; // All session entries
|
||||
sessionFile: string | null; // Current session file
|
||||
previousSessionFile: string | null; // Previous session file
|
||||
reason: "start" | "switch" | "branch" | "new";
|
||||
interface CustomToolSessionEvent {
|
||||
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -195,6 +251,8 @@ interface ToolSessionEvent {
|
|||
- `switch`: User switched to a different session (`/resume`)
|
||||
- `branch`: User branched from a previous message (`/branch`)
|
||||
- `new`: User started a new session (`/new`)
|
||||
- `tree`: User navigated to a different point in the session tree (`/tree`)
|
||||
- `shutdown`: Process is exiting (Ctrl+C, Ctrl+D, or SIGTERM) - use to cleanup resources
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
|
|
@ -210,9 +268,11 @@ const factory: CustomToolFactory = (pi) => {
|
|||
let items: string[] = [];
|
||||
|
||||
// Reconstruct state from session entries
|
||||
const reconstructState = (event: ToolSessionEvent) => {
|
||||
const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
if (event.reason === "shutdown") return;
|
||||
|
||||
items = [];
|
||||
for (const entry of event.entries) {
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (msg.role !== "toolResult") continue;
|
||||
|
|
@ -233,7 +293,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
|
||||
onSession: reconstructState,
|
||||
|
||||
async execute(toolCallId, params) {
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
// Modify items...
|
||||
items.push("new item");
|
||||
|
||||
|
|
@ -254,7 +314,7 @@ This pattern ensures:
|
|||
|
||||
## Custom Rendering
|
||||
|
||||
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional.
|
||||
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. See [tui.md](tui.md) for the full component API.
|
||||
|
||||
### How It Works
|
||||
|
||||
|
|
@ -355,7 +415,7 @@ If `renderCall` or `renderResult` is not defined or throws an error:
|
|||
## Execute Function
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, args, signal, onUpdate) {
|
||||
async execute(toolCallId, args, onUpdate, ctx, signal) {
|
||||
// Type assertion for params (TypeBox schema doesn't flow through)
|
||||
const params = args as { action: "list" | "add"; text?: string };
|
||||
|
||||
|
|
@ -387,13 +447,16 @@ const factory: CustomToolFactory = (pi) => {
|
|||
// Shared state
|
||||
let connection = null;
|
||||
|
||||
const handleSession = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
if (event.reason === "shutdown") {
|
||||
connection?.close();
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
{ name: "db_connect", ... },
|
||||
{ name: "db_query", ... },
|
||||
{
|
||||
name: "db_close",
|
||||
dispose() { connection?.close(); }
|
||||
},
|
||||
{ name: "db_connect", onSession: handleSession, ... },
|
||||
{ name: "db_query", onSession: handleSession, ... },
|
||||
{ name: "db_close", onSession: handleSession, ... },
|
||||
];
|
||||
};
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,385 +0,0 @@
|
|||
# Hooks v2: Context Control + Commands
|
||||
|
||||
Issue: #289
|
||||
|
||||
## Motivation
|
||||
|
||||
Enable features like session stacking (`/pop`) as hooks, not core code. Core provides primitives, hooks implement features.
|
||||
|
||||
## Primitives
|
||||
|
||||
| Primitive | Purpose |
|
||||
|-----------|---------|
|
||||
| `ctx.saveEntry({type, ...})` | Persist custom entry to session |
|
||||
| `pi.on("context", handler)` | Transform messages before LLM |
|
||||
| `ctx.rebuildContext()` | Trigger context rebuild |
|
||||
| `pi.command(name, opts)` | Register slash command |
|
||||
|
||||
## Extended HookEventContext
|
||||
|
||||
```typescript
|
||||
interface HookEventContext {
|
||||
// Existing
|
||||
exec, ui, hasUI, cwd, sessionFile
|
||||
|
||||
// State (read-only)
|
||||
model: Model<any> | null;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
entries: readonly SessionEntry[];
|
||||
|
||||
// Utilities
|
||||
findModel(provider: string, id: string): Model<any> | null;
|
||||
availableModels(): Promise<Model<any>[]>;
|
||||
resolveApiKey(model: Model<any>): Promise<string | undefined>;
|
||||
|
||||
// Mutation
|
||||
saveEntry(entry: { type: string; [k: string]: unknown }): Promise<void>;
|
||||
rebuildContext(): Promise<void>;
|
||||
}
|
||||
|
||||
interface ContextMessage {
|
||||
message: AppMessage;
|
||||
entryIndex: number | null; // null = synthetic
|
||||
}
|
||||
|
||||
interface ContextEvent {
|
||||
type: "context";
|
||||
entries: readonly SessionEntry[];
|
||||
messages: ContextMessage[];
|
||||
}
|
||||
```
|
||||
|
||||
Commands also get: `args`, `argsRaw`, `signal`, `setModel()`, `setThinkingLevel()`.
|
||||
|
||||
## Stacking: Design
|
||||
|
||||
### Entry Format
|
||||
|
||||
```typescript
|
||||
interface StackPopEntry {
|
||||
type: "stack_pop";
|
||||
backToIndex: number;
|
||||
summary: string;
|
||||
prePopSummary?: string; // when crossing compaction
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Crossing Compaction
|
||||
|
||||
Entries are never deleted. Raw data always available.
|
||||
|
||||
When `backToIndex < compaction.firstKeptEntryIndex`:
|
||||
1. Read raw entries `[0, backToIndex)` → summarize → `prePopSummary`
|
||||
2. Read raw entries `[backToIndex, now)` → summarize → `summary`
|
||||
|
||||
### Context Algorithm: Later Wins
|
||||
|
||||
Assign sequential IDs to ranges. On overlap, highest ID wins.
|
||||
|
||||
```
|
||||
Compaction at 40: range [0, 30) id=0
|
||||
StackPop at 50, backTo=20, prePopSummary: ranges [0, 20) id=1, [20, 50) id=2
|
||||
|
||||
Index 0-19: id=0 and id=1 cover → id=1 wins (prePopSummary)
|
||||
Index 20-29: id=0 and id=2 cover → id=2 wins (popSummary)
|
||||
Index 30-49: id=2 covers → id=2 (already emitted at 20)
|
||||
Index 50+: no coverage → include as messages
|
||||
```
|
||||
|
||||
## Complex Scenario Trace
|
||||
|
||||
```
|
||||
Initial: [msg1, msg2, msg3, msg4, msg5]
|
||||
idx: 1, 2, 3, 4, 5
|
||||
|
||||
Compaction triggers:
|
||||
[msg1-5, compaction{firstKept:4, summary:C1}]
|
||||
idx: 1-5, 6
|
||||
Context: [C1, msg4, msg5]
|
||||
|
||||
User continues:
|
||||
[..., compaction, msg4, msg5, msg6, msg7]
|
||||
idx: 6, 4*, 5*, 7, 8 (* kept from before)
|
||||
|
||||
User does /pop to msg2 (index 2):
|
||||
- backTo=2 < firstKept=4 → crossing!
|
||||
- prePopSummary: summarize raw [0,2) → P1
|
||||
- summary: summarize raw [2,8) → S1
|
||||
- save: stack_pop{backTo:2, summary:S1, prePopSummary:P1} at index 9
|
||||
|
||||
Ranges:
|
||||
compaction [0,4) id=0
|
||||
prePopSummary [0,2) id=1
|
||||
popSummary [2,9) id=2
|
||||
|
||||
Context build:
|
||||
idx 0: covered by id=0,1 → id=1 wins, emit P1
|
||||
idx 1: covered by id=0,1 → id=1 (already emitted)
|
||||
idx 2: covered by id=0,2 → id=2 wins, emit S1
|
||||
idx 3-8: covered by id=0 or id=2 → id=2 (already emitted)
|
||||
idx 9: stack_pop entry, skip
|
||||
idx 10+: not covered, include as messages
|
||||
|
||||
Result: [P1, S1, msg10+]
|
||||
|
||||
User continues, another compaction:
|
||||
[..., stack_pop, msg10, msg11, msg12, compaction{firstKept:11, summary:C2}]
|
||||
idx: 9, 10, 11, 12, 13
|
||||
|
||||
Ranges:
|
||||
compaction@6 [0,4) id=0
|
||||
prePopSummary [0,2) id=1
|
||||
popSummary [2,9) id=2
|
||||
compaction@13 [0,11) id=3 ← this now covers previous ranges!
|
||||
|
||||
Context build:
|
||||
idx 0-10: covered by multiple, id=3 wins → emit C2 at idx 0
|
||||
idx 11+: include as messages
|
||||
|
||||
Result: [C2, msg11, msg12]
|
||||
|
||||
C2's summary text includes info from P1 and S1 (they were in context when C2 was generated).
|
||||
```
|
||||
|
||||
The "later wins" rule naturally handles all cases.
|
||||
|
||||
## Core Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `session-manager.ts` | `saveEntry()`, `buildSessionContext()` returns `ContextMessage[]` |
|
||||
| `hooks/types.ts` | `ContextEvent`, `ContextMessage`, extended context, command types |
|
||||
| `hooks/loader.ts` | Track commands |
|
||||
| `hooks/runner.ts` | `setStateCallbacks()`, `emitContext()`, command methods |
|
||||
| `agent-session.ts` | `saveEntry()`, `rebuildContext()`, state callbacks |
|
||||
| `interactive-mode.ts` | Command handling, autocomplete |
|
||||
|
||||
## Stacking Hook: Complete Implementation
|
||||
|
||||
```typescript
|
||||
import { complete } from "@mariozechner/pi-ai";
|
||||
import type { HookAPI, AppMessage, SessionEntry, ContextMessage } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function(pi: HookAPI) {
|
||||
pi.command("pop", {
|
||||
description: "Pop to previous turn, summarizing work",
|
||||
handler: async (ctx) => {
|
||||
const entries = ctx.entries as SessionEntry[];
|
||||
|
||||
// Get user turns
|
||||
const turns = entries
|
||||
.map((e, i) => ({ e, i }))
|
||||
.filter(({ e }) => e.type === "message" && (e as any).message.role === "user")
|
||||
.map(({ e, i }) => ({ idx: i, text: preview((e as any).message) }));
|
||||
|
||||
if (turns.length < 2) return { status: "Need at least 2 turns" };
|
||||
|
||||
// Select target (skip last turn - that's current)
|
||||
const options = turns.slice(0, -1).map(t => `[${t.idx}] ${t.text}`);
|
||||
const selected = ctx.args[0]
|
||||
? options.find(o => o.startsWith(`[${ctx.args[0]}]`))
|
||||
: await ctx.ui.select("Pop to:", options);
|
||||
|
||||
if (!selected) return;
|
||||
const backTo = parseInt(selected.match(/\[(\d+)\]/)![1]);
|
||||
|
||||
// Check compaction crossing
|
||||
const compactions = entries.filter(e => e.type === "compaction") as any[];
|
||||
const latestCompaction = compactions[compactions.length - 1];
|
||||
const crossing = latestCompaction && backTo < latestCompaction.firstKeptEntryIndex;
|
||||
|
||||
// Generate summaries
|
||||
let prePopSummary: string | undefined;
|
||||
if (crossing) {
|
||||
ctx.ui.notify("Crossing compaction, generating pre-pop summary...", "info");
|
||||
const preMsgs = getMessages(entries.slice(0, backTo));
|
||||
prePopSummary = await summarize(preMsgs, ctx, "context before this work");
|
||||
}
|
||||
|
||||
const popMsgs = getMessages(entries.slice(backTo));
|
||||
const summary = await summarize(popMsgs, ctx, "completed work");
|
||||
|
||||
// Save and rebuild
|
||||
await ctx.saveEntry({
|
||||
type: "stack_pop",
|
||||
backToIndex: backTo,
|
||||
summary,
|
||||
prePopSummary,
|
||||
});
|
||||
|
||||
await ctx.rebuildContext();
|
||||
return { status: `Popped to turn ${backTo}` };
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("context", (event, ctx) => {
|
||||
const hasPops = event.entries.some(e => e.type === "stack_pop");
|
||||
if (!hasPops) return;
|
||||
|
||||
// Collect ranges with IDs
|
||||
let rangeId = 0;
|
||||
const ranges: Array<{from: number; to: number; summary: string; id: number}> = [];
|
||||
|
||||
for (let i = 0; i < event.entries.length; i++) {
|
||||
const e = event.entries[i] as any;
|
||||
if (e.type === "compaction") {
|
||||
ranges.push({ from: 0, to: e.firstKeptEntryIndex, summary: e.summary, id: rangeId++ });
|
||||
}
|
||||
if (e.type === "stack_pop") {
|
||||
if (e.prePopSummary) {
|
||||
ranges.push({ from: 0, to: e.backToIndex, summary: e.prePopSummary, id: rangeId++ });
|
||||
}
|
||||
ranges.push({ from: e.backToIndex, to: i, summary: e.summary, id: rangeId++ });
|
||||
}
|
||||
}
|
||||
|
||||
// Build messages
|
||||
const messages: ContextMessage[] = [];
|
||||
const emitted = new Set<number>();
|
||||
|
||||
for (let i = 0; i < event.entries.length; i++) {
|
||||
const covering = ranges.filter(r => r.from <= i && i < r.to);
|
||||
|
||||
if (covering.length) {
|
||||
const winner = covering.reduce((a, b) => a.id > b.id ? a : b);
|
||||
if (i === winner.from && !emitted.has(winner.id)) {
|
||||
messages.push({
|
||||
message: { role: "user", content: `[Summary]\n\n${winner.summary}`, timestamp: Date.now() } as AppMessage,
|
||||
entryIndex: null
|
||||
});
|
||||
emitted.add(winner.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const e = event.entries[i];
|
||||
if (e.type === "message") {
|
||||
messages.push({ message: (e as any).message, entryIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
return { messages };
|
||||
});
|
||||
}
|
||||
|
||||
function getMessages(entries: SessionEntry[]): AppMessage[] {
|
||||
return entries.filter(e => e.type === "message").map(e => (e as any).message);
|
||||
}
|
||||
|
||||
function preview(msg: AppMessage): string {
|
||||
const text = typeof msg.content === "string" ? msg.content
|
||||
: (msg.content as any[]).filter(c => c.type === "text").map(c => c.text).join(" ");
|
||||
return text.slice(0, 40) + (text.length > 40 ? "..." : "");
|
||||
}
|
||||
|
||||
async function summarize(msgs: AppMessage[], ctx: any, purpose: string): Promise<string> {
|
||||
const apiKey = await ctx.resolveApiKey(ctx.model);
|
||||
const resp = await complete(ctx.model, {
|
||||
messages: [...msgs, { role: "user", content: `Summarize as "${purpose}". Be concise.`, timestamp: Date.now() }]
|
||||
}, { apiKey, maxTokens: 2000, signal: ctx.signal });
|
||||
return resp.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n");
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Session Resumed Without Hook
|
||||
|
||||
User has stacking hook, does `/pop`, saves `stack_pop` entry. Later removes hook and resumes session.
|
||||
|
||||
**What happens:**
|
||||
1. Core loads all entries (including `stack_pop`)
|
||||
2. Core's `buildSessionContext()` ignores unknown types, returns compaction + message entries
|
||||
3. `context` event fires, but no handler processes `stack_pop`
|
||||
4. Core's messages pass through unchanged
|
||||
|
||||
**Result:** Messages that were "popped" return to context. The pop is effectively undone.
|
||||
|
||||
**Why this is OK:**
|
||||
- Session file is intact, no data lost
|
||||
- If compaction happened after pop, the compaction summary captured the popped state
|
||||
- User removed the hook, so hook's behavior (hiding messages) is gone
|
||||
- User can re-add hook to restore stacking behavior
|
||||
|
||||
**Mitigation:** Could warn on session load if unknown entry types found:
|
||||
```typescript
|
||||
// In session load
|
||||
const unknownTypes = entries
|
||||
.map(e => e.type)
|
||||
.filter(t => !knownTypes.has(t));
|
||||
if (unknownTypes.length) {
|
||||
console.warn(`Session has entries of unknown types: ${unknownTypes.join(", ")}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Added to Existing Session
|
||||
|
||||
User has old session without stacking. Adds stacking hook, does `/pop`.
|
||||
|
||||
**What happens:**
|
||||
1. Hook saves `stack_pop` entry
|
||||
2. `context` event fires, hook processes it
|
||||
3. Works normally
|
||||
|
||||
No issue. Hook processes entries it recognizes, ignores others.
|
||||
|
||||
### Multiple Hooks with Different Entry Types
|
||||
|
||||
Hook A handles `type_a` entries, Hook B handles `type_b` entries.
|
||||
|
||||
**What happens:**
|
||||
1. `context` event chains through both hooks
|
||||
2. Each hook checks for its entry types, passes through if none found
|
||||
3. Each hook's transforms are applied in order
|
||||
|
||||
**Best practice:** Hooks should:
|
||||
- Only process their own entry types
|
||||
- Return `undefined` (pass through) if no relevant entries
|
||||
- Use prefixed type names: `myhook_pop`, `myhook_prune`
|
||||
|
||||
### Conflicting Hooks
|
||||
|
||||
Two hooks both try to handle the same entry type (e.g., both handle `compaction`).
|
||||
|
||||
**What happens:**
|
||||
- Later hook (project > global) wins in the chain
|
||||
- Earlier hook's transform is overwritten
|
||||
|
||||
**Mitigation:**
|
||||
- Core entry types (`compaction`, `message`, etc.) should not be overridden by hooks
|
||||
- Hooks should use unique prefixed type names
|
||||
- Document which types are "reserved"
|
||||
|
||||
### Session with Future Entry Types
|
||||
|
||||
User downgrades pi version, session has entry types from newer version.
|
||||
|
||||
**What happens:**
|
||||
- Same as "hook removed" - unknown types ignored
|
||||
- Core handles what it knows, hooks handle what they know
|
||||
|
||||
**Session file is forward-compatible:** Unknown entries are preserved in file, just not processed.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
| Phase | Scope | LOC |
|
||||
|-------|-------|-----|
|
||||
| v2.0 | `saveEntry`, `context` event, `rebuildContext`, extended context | ~150 |
|
||||
| v2.1 | `pi.command()`, TUI integration, autocomplete | ~200 |
|
||||
| v2.2 | Example hooks, documentation | ~300 |
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `ContextMessage` type, update `buildSessionContext()` return type
|
||||
2. `saveEntry()` in session-manager
|
||||
3. `context` event in runner with chaining
|
||||
4. State callbacks interface and wiring
|
||||
5. `rebuildContext()` in agent-session
|
||||
6. Manual test with simple hook
|
||||
7. Command registration in loader
|
||||
8. Command invocation in runner
|
||||
9. TUI command handling + autocomplete
|
||||
10. Stacking example hook
|
||||
11. Pruning example hook
|
||||
12. Update hooks.md
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -36,9 +36,9 @@ Send a user prompt to the agent. Returns immediately; events stream asynchronous
|
|||
{"id": "req-1", "type": "prompt", "message": "Hello, world!"}
|
||||
```
|
||||
|
||||
With attachments:
|
||||
With images:
|
||||
```json
|
||||
{"type": "prompt", "message": "What's in this image?", "attachments": [...]}
|
||||
{"type": "prompt", "message": "What's in this image?", "images": [{"type": "image", "source": {"type": "base64", "mediaType": "image/png", "data": "..."}}]}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
|
@ -46,7 +46,7 @@ Response:
|
|||
{"id": "req-1", "type": "response", "command": "prompt", "success": true}
|
||||
```
|
||||
|
||||
The `attachments` field is optional. See [Attachments](#attachments) for the schema.
|
||||
The `images` field is optional. Each image uses `ImageContent` format with base64 or URL source.
|
||||
|
||||
#### queue_message
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ Response:
|
|||
}
|
||||
```
|
||||
|
||||
Messages are `AppMessage` objects (see [Message Types](#message-types)).
|
||||
Messages are `AgentMessage` objects (see [Message Types](#message-types)).
|
||||
|
||||
### Model
|
||||
|
||||
|
|
@ -289,8 +289,10 @@ Response:
|
|||
"command": "compact",
|
||||
"success": true,
|
||||
"data": {
|
||||
"summary": "Summary of conversation...",
|
||||
"firstKeptEntryId": "abc123",
|
||||
"tokensBefore": 150000,
|
||||
"summary": "Summary of conversation..."
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -491,7 +493,7 @@ If a hook cancelled the switch:
|
|||
Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from.
|
||||
|
||||
```json
|
||||
{"type": "branch", "entryIndex": 2}
|
||||
{"type": "branch", "entryId": "abc123"}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
|
@ -530,8 +532,8 @@ Response:
|
|||
"success": true,
|
||||
"data": {
|
||||
"messages": [
|
||||
{"entryIndex": 0, "text": "First prompt..."},
|
||||
{"entryIndex": 2, "text": "Second prompt..."}
|
||||
{"entryId": "abc123", "text": "First prompt..."},
|
||||
{"entryId": "def456", "text": "Second prompt..."}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -618,7 +620,7 @@ A turn consists of one assistant response plus any resulting tool calls and resu
|
|||
|
||||
### message_start / message_end
|
||||
|
||||
Emitted when a message begins and completes. The `message` field contains an `AppMessage`.
|
||||
Emitted when a message begins and completes. The `message` field contains an `AgentMessage`.
|
||||
|
||||
```json
|
||||
{"type": "message_start", "message": {...}}
|
||||
|
|
@ -717,20 +719,27 @@ Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_upd
|
|||
Emitted when automatic compaction runs (when context is nearly full).
|
||||
|
||||
```json
|
||||
{"type": "auto_compaction_start"}
|
||||
{"type": "auto_compaction_start", "reason": "threshold"}
|
||||
```
|
||||
|
||||
The `reason` field is `"threshold"` (context getting large) or `"overflow"` (context exceeded limit).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "auto_compaction_end",
|
||||
"result": {
|
||||
"summary": "Summary of conversation...",
|
||||
"firstKeptEntryId": "abc123",
|
||||
"tokensBefore": 150000,
|
||||
"summary": "Summary of conversation..."
|
||||
"details": {}
|
||||
},
|
||||
"aborted": false
|
||||
"aborted": false,
|
||||
"willRetry": false
|
||||
}
|
||||
```
|
||||
|
||||
If `reason` was `"overflow"` and compaction succeeds, `willRetry` is `true` and the agent will automatically retry the prompt.
|
||||
|
||||
If compaction was aborted, `result` is `null` and `aborted` is `true`.
|
||||
|
||||
### auto_retry_start / auto_retry_end
|
||||
|
|
@ -806,7 +815,7 @@ Parse errors:
|
|||
|
||||
Source files:
|
||||
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `AgentEvent`
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`
|
||||
- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`
|
||||
- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> pi can help you use the SDK. Ask it to build an integration for your use case.
|
||||
|
||||
# SDK
|
||||
|
||||
The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows.
|
||||
|
|
@ -81,26 +83,32 @@ interface AgentSession {
|
|||
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
|
||||
|
||||
// Session info
|
||||
sessionFile: string | null;
|
||||
sessionFile: string | undefined; // undefined for in-memory
|
||||
sessionId: string;
|
||||
|
||||
// Model control
|
||||
setModel(model: Model): Promise<void>;
|
||||
setThinkingLevel(level: ThinkingLevel): void;
|
||||
cycleModel(): Promise<ModelCycleResult | null>;
|
||||
cycleThinkingLevel(): ThinkingLevel | null;
|
||||
cycleModel(): Promise<ModelCycleResult | undefined>;
|
||||
cycleThinkingLevel(): ThinkingLevel | undefined;
|
||||
|
||||
// State access
|
||||
agent: Agent;
|
||||
model: Model | null;
|
||||
model: Model | undefined;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
messages: AppMessage[];
|
||||
messages: AgentMessage[];
|
||||
isStreaming: boolean;
|
||||
|
||||
// Session management
|
||||
reset(): Promise<void>;
|
||||
branch(entryIndex: number): Promise<{ selectedText: string; skipped: boolean }>;
|
||||
switchSession(sessionPath: string): Promise<void>;
|
||||
reset(): Promise<boolean>; // Returns false if cancelled by hook
|
||||
switchSession(sessionPath: string): Promise<boolean>;
|
||||
|
||||
// Branching
|
||||
branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
|
||||
|
||||
// Hook message injection
|
||||
sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise<void>;
|
||||
|
||||
// Compaction
|
||||
compact(customInstructions?: string): Promise<CompactionResult>;
|
||||
|
|
@ -122,7 +130,7 @@ The `Agent` class (from `@mariozechner/pi-agent-core`) handles the core LLM inte
|
|||
// Access current state
|
||||
const state = session.agent.state;
|
||||
|
||||
// state.messages: AppMessage[] - conversation history
|
||||
// state.messages: AgentMessage[] - conversation history
|
||||
// state.model: Model - current model
|
||||
// state.thinkingLevel: ThinkingLevel - current thinking level
|
||||
// state.systemPrompt: string - system prompt
|
||||
|
|
@ -394,10 +402,10 @@ const { session } = await createAgentSession({
|
|||
|
||||
```typescript
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { createAgentSession, discoverCustomTools, type CustomAgentTool } from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, discoverCustomTools, type CustomTool } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Inline custom tool
|
||||
const myTool: CustomAgentTool = {
|
||||
const myTool: CustomTool = {
|
||||
name: "my_tool",
|
||||
label: "My Tool",
|
||||
description: "Does something useful",
|
||||
|
|
@ -436,18 +444,38 @@ import { createAgentSession, discoverHooks, type HookFactory } from "@mariozechn
|
|||
|
||||
// Inline hook
|
||||
const loggingHook: HookFactory = (api) => {
|
||||
// Log tool calls
|
||||
api.on("tool_call", async (event) => {
|
||||
console.log(`Tool: ${event.toolName}`);
|
||||
return undefined; // Don't block
|
||||
});
|
||||
|
||||
// Block dangerous commands
|
||||
api.on("tool_call", async (event) => {
|
||||
// Block dangerous commands
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
return { block: true, reason: "Dangerous command" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Register custom slash command
|
||||
api.registerCommand("stats", {
|
||||
description: "Show session stats",
|
||||
handler: async (ctx) => {
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
ctx.ui.notify(`${entries.length} entries`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
// Inject messages
|
||||
api.sendMessage({
|
||||
customType: "my-hook",
|
||||
content: "Hook initialized",
|
||||
display: false, // Hidden from TUI
|
||||
}, false); // Don't trigger agent turn
|
||||
|
||||
// Persist hook state
|
||||
api.appendEntry("my-hook", { initialized: true });
|
||||
};
|
||||
|
||||
// Replace discovery
|
||||
|
|
@ -472,7 +500,15 @@ const { session } = await createAgentSession({
|
|||
});
|
||||
```
|
||||
|
||||
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts)
|
||||
Hook API methods:
|
||||
- `api.on(event, handler)` - Subscribe to events
|
||||
- `api.sendMessage(message, triggerTurn?)` - Inject message (creates `CustomMessageEntry`)
|
||||
- `api.appendEntry(customType, data?)` - Persist hook state (not in LLM context)
|
||||
- `api.registerCommand(name, options)` - Register custom slash command
|
||||
- `api.registerMessageRenderer(customType, renderer)` - Custom TUI rendering
|
||||
- `api.exec(command, args, options?)` - Execute shell commands
|
||||
|
||||
> See [examples/sdk/06-hooks.ts](../examples/sdk/06-hooks.ts) and [docs/hooks.md](hooks.md)
|
||||
|
||||
### Skills
|
||||
|
||||
|
|
@ -560,6 +596,8 @@ const { session } = await createAgentSession({
|
|||
|
||||
### Session Management
|
||||
|
||||
Sessions use a tree structure with `id`/`parentId` linking, enabling in-place branching.
|
||||
|
||||
```typescript
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
|
|
@ -597,12 +635,32 @@ const customDir = "/path/to/my-sessions";
|
|||
const { session } = await createAgentSession({
|
||||
sessionManager: SessionManager.create(process.cwd(), customDir),
|
||||
});
|
||||
// Also works with list and continueRecent:
|
||||
// SessionManager.list(process.cwd(), customDir);
|
||||
// SessionManager.continueRecent(process.cwd(), customDir);
|
||||
```
|
||||
|
||||
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts)
|
||||
**SessionManager tree API:**
|
||||
|
||||
```typescript
|
||||
const sm = SessionManager.open("/path/to/session.jsonl");
|
||||
|
||||
// Tree traversal
|
||||
const entries = sm.getEntries(); // All entries (excludes header)
|
||||
const tree = sm.getTree(); // Full tree structure
|
||||
const path = sm.getPath(); // Path from root to current leaf
|
||||
const leaf = sm.getLeafEntry(); // Current leaf entry
|
||||
const entry = sm.getEntry(id); // Get entry by ID
|
||||
const children = sm.getChildren(id); // Direct children of entry
|
||||
|
||||
// Labels
|
||||
const label = sm.getLabel(id); // Get label for entry
|
||||
sm.appendLabelChange(id, "checkpoint"); // Set label
|
||||
|
||||
// Branching
|
||||
sm.branch(entryId); // Move leaf to earlier entry
|
||||
sm.branchWithSummary(id, "Summary..."); // Branch with context summary
|
||||
sm.createBranchedSession(leafId); // Extract path to new file
|
||||
```
|
||||
|
||||
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts) and [docs/session.md](session.md)
|
||||
|
||||
### Settings Management
|
||||
|
||||
|
|
@ -737,7 +795,7 @@ import {
|
|||
readTool,
|
||||
bashTool,
|
||||
type HookFactory,
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Set up auth storage (custom location)
|
||||
|
|
@ -760,7 +818,7 @@ const auditHook: HookFactory = (api) => {
|
|||
};
|
||||
|
||||
// Inline tool
|
||||
const statusTool: CustomAgentTool = {
|
||||
const statusTool: CustomTool = {
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: "Get system status",
|
||||
|
|
@ -876,7 +934,7 @@ createGrepTool, createFindTool, createLsTool
|
|||
// Types
|
||||
type CreateAgentSessionOptions
|
||||
type CreateAgentSessionResult
|
||||
type CustomAgentTool
|
||||
type CustomTool
|
||||
type HookFactory
|
||||
type Skill
|
||||
type FileSlashCommand
|
||||
|
|
@ -888,7 +946,21 @@ type Tool
|
|||
For hook types, import from the hooks subpath:
|
||||
|
||||
```typescript
|
||||
import type { HookAPI, HookEvent, ToolCallEvent } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type {
|
||||
HookAPI,
|
||||
HookMessage,
|
||||
HookFactory,
|
||||
HookEventContext,
|
||||
HookCommandContext,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
} from "@mariozechner/pi-coding-agent/hooks";
|
||||
```
|
||||
|
||||
For message utilities:
|
||||
|
||||
```typescript
|
||||
import { isHookMessage, createHookMessage } from "@mariozechner/pi-coding-agent";
|
||||
```
|
||||
|
||||
For config utilities:
|
||||
|
|
|
|||
441
packages/coding-agent/docs/session-tree-plan.md
Normal file
441
packages/coding-agent/docs/session-tree-plan.md
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
# Session Tree Implementation Plan
|
||||
|
||||
Reference: [session-tree.md](./session-tree.md)
|
||||
|
||||
## Phase 1: SessionManager Core ✅
|
||||
|
||||
- [x] Update entry types with `id`, `parentId` fields (using SessionEntryBase)
|
||||
- [x] Add `version` field to `SessionHeader`
|
||||
- [x] Change `CompactionEntry.firstKeptEntryIndex` → `firstKeptEntryId`
|
||||
- [x] Add `BranchSummaryEntry` type
|
||||
- [x] Add `CustomEntry` type for hooks
|
||||
- [x] Add `byId: Map<string, SessionEntry>` index
|
||||
- [x] Add `leafId: string` tracking
|
||||
- [x] Implement `getPath(fromId?)` tree traversal
|
||||
- [x] Implement `getTree()` returning `SessionTreeNode[]`
|
||||
- [x] Implement `getEntry(id)` lookup
|
||||
- [x] Implement `getLeafUuid()` and `getLeafEntry()` helpers
|
||||
- [x] Update `_buildIndex()` to populate `byId` map
|
||||
- [x] Rename `saveXXX()` to `appendXXX()` (returns id, advances leaf)
|
||||
- [x] Add `appendCustomEntry(customType, data)` for hooks
|
||||
- [x] Update `buildSessionContext()` to use `getPath()` traversal
|
||||
|
||||
## Phase 2: Migration ✅
|
||||
|
||||
- [x] Add `CURRENT_SESSION_VERSION = 2` constant
|
||||
- [x] Implement `migrateV1ToV2()` with extensible migration chain
|
||||
- [x] Update `setSessionFile()` to detect version and migrate
|
||||
- [x] Implement `_rewriteFile()` for post-migration persistence
|
||||
- [x] Handle `firstKeptEntryIndex` → `firstKeptEntryId` conversion in migration
|
||||
|
||||
## Phase 3: Branching ✅
|
||||
|
||||
- [x] Implement `branch(id)` - switch leaf pointer
|
||||
- [x] Implement `branchWithSummary(id, summary)` - create summary entry
|
||||
- [x] Implement `createBranchedSession(leafId)` - extract path to new file
|
||||
- [x] Update `AgentSession.branch()` to use new API
|
||||
|
||||
## Phase 4: Compaction Integration ✅
|
||||
|
||||
- [x] Update `compaction.ts` to work with IDs
|
||||
- [x] Update `prepareCompaction()` to return `firstKeptEntryId`
|
||||
- [x] Update `compact()` to return `CompactionResult` with `firstKeptEntryId`
|
||||
- [x] Update `AgentSession` compaction methods
|
||||
- [x] Add `firstKeptEntryId` to `before_compact` hook event
|
||||
|
||||
## Phase 5: Testing ✅
|
||||
|
||||
- [x] `migration.test.ts` - v1 to v2 migration, idempotency
|
||||
- [x] `build-context.test.ts` - context building with tree structure, compaction, branches
|
||||
- [x] `tree-traversal.test.ts` - append operations, getPath, getTree, branching
|
||||
- [x] `file-operations.test.ts` - loadEntriesFromFile, findMostRecentSession
|
||||
- [x] `save-entry.test.ts` - custom entry integration
|
||||
- [x] Update existing compaction tests for new types
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Compaction Refactor
|
||||
|
||||
- [x] Use `CompactionResult` type for hook return value
|
||||
- [x] Make `CompactionEntry<T>` generic with optional `details?: T` field for hook-specific data
|
||||
- [x] Make `CompactionResult<T>` generic to match
|
||||
- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields
|
||||
- [x] Update `before_compact` event:
|
||||
- Pass `preparation: CompactionPreparation` instead of individual fields
|
||||
- Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string`
|
||||
- Keep: `customInstructions`, `model`, `signal`
|
||||
- Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries`
|
||||
- [x] Update hook example `custom-compaction.ts` to use new API
|
||||
- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions
|
||||
- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile`
|
||||
|
||||
Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers.
|
||||
|
||||
### Branch Summary Design ✅
|
||||
|
||||
Current type:
|
||||
```typescript
|
||||
export interface BranchSummaryEntry extends SessionEntryBase {
|
||||
type: "branch_summary";
|
||||
summary: string;
|
||||
fromId: string; // References the abandoned leaf
|
||||
fromHook?: boolean; // Whether summary was generated by a hook
|
||||
details?: unknown; // File tracking: { readFiles, modifiedFiles }
|
||||
}
|
||||
```
|
||||
|
||||
- [x] `fromId` field references the abandoned leaf
|
||||
- [x] `fromHook` field distinguishes pi-generated vs hook-generated summaries
|
||||
- [x] `details` field for file tracking
|
||||
- [x] Branch summarizer implemented with structured output format
|
||||
- [x] Uses serialization approach (same as compaction) to prevent model confusion
|
||||
- [x] Tests for `branchWithSummary()` flow
|
||||
|
||||
### Entry Labels ✅
|
||||
|
||||
- [x] Add `LabelEntry` type with `targetId` and `label` fields
|
||||
- [x] Add `labelsById: Map<string, string>` private field
|
||||
- [x] Build labels map in `_buildIndex()` via linear scan
|
||||
- [x] Add `getLabel(id)` method
|
||||
- [x] Add `appendLabelChange(targetId, label)` method (undefined clears)
|
||||
- [x] Update `createBranchedSession()` to filter out LabelEntry and recreate from resolved map
|
||||
- [x] `buildSessionContext()` already ignores LabelEntry (only handles message types)
|
||||
- [x] Add `label?: string` to `SessionTreeNode`, populated by `getTree()`
|
||||
- [x] Display labels in UI (tree-selector shows labels)
|
||||
- [x] `/label` command (implemented in tree-selector)
|
||||
|
||||
### CustomMessageEntry<T>
|
||||
|
||||
Hook-injected messages that participate in LLM context. Unlike `CustomEntry<T>` (for hook state only), these are sent to the model.
|
||||
|
||||
```typescript
|
||||
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
||||
type: "custom_message";
|
||||
customType: string; // Hook identifier
|
||||
content: string | (TextContent | ImageContent)[]; // Message content (same as UserMessage)
|
||||
details?: T; // Hook-specific data for state reconstruction on reload
|
||||
display: boolean; // Whether to display in TUI
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- [x] Type definition matching plan
|
||||
- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager
|
||||
- [x] `buildSessionContext()` includes custom_message entries as user messages
|
||||
- [x] Exported from main index
|
||||
- [x] TUI rendering:
|
||||
- `display: false` - hidden entirely
|
||||
- `display: true` - rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
|
||||
- [x] `registerCustomMessageRenderer(customType, renderer)` in HookAPI for custom renderers
|
||||
- [x] Renderer returns inner Component, TUI wraps in styled Box
|
||||
|
||||
### Hook API Changes ✅
|
||||
|
||||
**Renamed:**
|
||||
- `renderCustomMessage()` → `registerCustomMessageRenderer()`
|
||||
|
||||
**New: `sendMessage()` ✅**
|
||||
|
||||
Replaces `send()`. Always creates CustomMessageEntry, never user messages.
|
||||
|
||||
```typescript
|
||||
type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, 'customType' | 'content' | 'display' | 'details'>;
|
||||
|
||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- Uses agent's queue mechanism with `_hookData` marker on AppMessage
|
||||
- `message_end` handler routes based on marker presence
|
||||
- `AgentSession.sendHookMessage()` handles three cases:
|
||||
- Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end`
|
||||
- Not streaming + triggerTurn: direct append + `agent.continue()`
|
||||
- Not streaming + no trigger: direct append only
|
||||
- TUI updates via event (streaming) or explicit rebuild (non-streaming)
|
||||
|
||||
**New: `appendEntry()` ✅**
|
||||
|
||||
For hook state persistence (NOT in LLM context):
|
||||
|
||||
```typescript
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
```
|
||||
|
||||
Calls `sessionManager.appendCustomEntry()` directly.
|
||||
|
||||
**New: `registerCommand()` (types ✅, wiring TODO)**
|
||||
|
||||
```typescript
|
||||
// HookAPI (the `pi` object) - utilities available to all hooks:
|
||||
interface HookAPI {
|
||||
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
|
||||
appendEntry(customType: string, data?: unknown): void;
|
||||
registerCommand(name: string, options: RegisteredCommand): void;
|
||||
registerCustomMessageRenderer(customType: string, renderer: CustomMessageRenderer): void;
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
}
|
||||
|
||||
// HookEventContext - passed to event handlers, has stable context:
|
||||
interface HookEventContext {
|
||||
ui: HookUIContext;
|
||||
hasUI: boolean;
|
||||
cwd: string;
|
||||
sessionManager: SessionManager;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
// Note: exec moved to HookAPI, sessionManager/modelRegistry moved from SessionEventBase
|
||||
|
||||
// HookCommandContext - passed to command handlers:
|
||||
interface HookCommandContext {
|
||||
args: string; // Everything after /commandname
|
||||
ui: HookUIContext;
|
||||
hasUI: boolean;
|
||||
cwd: string;
|
||||
sessionManager: SessionManager;
|
||||
modelRegistry: ModelRegistry;
|
||||
}
|
||||
// Note: exec and sendMessage accessed via `pi` closure
|
||||
|
||||
registerCommand(name: string, options: {
|
||||
description?: string;
|
||||
handler: (ctx: HookCommandContext) => Promise<void>;
|
||||
}): void;
|
||||
```
|
||||
|
||||
Handler return:
|
||||
- `void` - command completed (use `sendMessage()` with `triggerTurn: true` to prompt LLM)
|
||||
|
||||
Wiring (all in AgentSession.prompt()):
|
||||
- [x] Add hook commands to autocomplete in interactive-mode
|
||||
- [x] `_tryExecuteHookCommand()` in AgentSession handles command execution
|
||||
- [x] Build HookCommandContext with ui (from hookRunner), exec, sessionManager, etc.
|
||||
- [x] If handler returns string, use as prompt text
|
||||
- [x] If handler returns undefined, return early (no LLM call)
|
||||
- [x] Works for all modes (interactive, RPC, print) via shared AgentSession
|
||||
|
||||
**New: `ui.custom()` ✅**
|
||||
|
||||
For arbitrary hook UI with keyboard focus:
|
||||
|
||||
```typescript
|
||||
interface HookUIContext {
|
||||
// ... existing: select, confirm, input, notify
|
||||
|
||||
/** Show custom component with keyboard focus. Call done() when finished. */
|
||||
custom(component: Component, done: () => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
See also: `CustomEntry<T>` for storing hook state that does NOT participate in context.
|
||||
|
||||
**New: `context` event ✅**
|
||||
|
||||
Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively.
|
||||
|
||||
```typescript
|
||||
interface ContextEvent {
|
||||
type: "context";
|
||||
/** Messages that will be sent to the LLM */
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface ContextEventResult {
|
||||
/** Modified messages to send instead */
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
// In HookAPI:
|
||||
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult | void>): void;
|
||||
```
|
||||
|
||||
Example use case: **Dynamic Context Pruning** ([discussion #330](https://github.com/badlogic/pi-mono/discussions/330))
|
||||
|
||||
Non-destructive pruning of tool results to reduce context size:
|
||||
|
||||
```typescript
|
||||
export default function(pi: HookAPI) {
|
||||
// Register /prune command
|
||||
pi.registerCommand("prune", {
|
||||
description: "Mark tool results for pruning",
|
||||
handler: async (ctx) => {
|
||||
// Show UI to select which tool results to prune
|
||||
// Append custom entry recording pruning decisions:
|
||||
// { toolResultId, strategy: "summary" | "truncate" | "remove" }
|
||||
pi.appendEntry("tool-result-pruning", { ... });
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept context before LLM call
|
||||
pi.on("context", async (event, ctx) => {
|
||||
// Find all pruning entries in session
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const pruningRules = entries
|
||||
.filter(e => e.type === "custom" && e.customType === "tool-result-pruning")
|
||||
.map(e => e.data);
|
||||
|
||||
// Apply pruning rules to messages
|
||||
const prunedMessages = applyPruning(event.messages, pruningRules);
|
||||
return { messages: prunedMessages };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Original tool results stay intact in session
|
||||
- Pruning is stored as custom entries, survives session reload
|
||||
- Works with branching (pruning entries are part of the tree)
|
||||
- Trade-off: cache busting on first submission after pruning
|
||||
|
||||
### Investigate: `context` event vs `before_agent_start` ✅
|
||||
|
||||
References:
|
||||
- [#324](https://github.com/badlogic/pi-mono/issues/324) - `before_agent_start` proposal
|
||||
- [#330](https://github.com/badlogic/pi-mono/discussions/330) - Dynamic Context Pruning (why `context` was added)
|
||||
|
||||
**Current `context` event:**
|
||||
- Fires before each LLM call within the agent loop
|
||||
- Receives `AgentMessage[]` (deep copy, safe to modify)
|
||||
- Returns `Message[]` (inconsistent with input type)
|
||||
- Modifications are transient (not persisted to session)
|
||||
- No TUI visibility of what was changed
|
||||
- Use case: non-destructive pruning, dynamic context manipulation
|
||||
|
||||
**Type inconsistency:** Event receives `AgentMessage[]` but result returns `Message[]`:
|
||||
```typescript
|
||||
interface ContextEvent {
|
||||
messages: AgentMessage[]; // Input
|
||||
}
|
||||
interface ContextEventResult {
|
||||
messages?: Message[]; // Output - different type!
|
||||
}
|
||||
```
|
||||
|
||||
Questions:
|
||||
- [ ] Should input/output both be `Message[]` (LLM format)?
|
||||
- [ ] Or both be `AgentMessage[]` with conversion happening after?
|
||||
- [ ] Where does `AgentMessage[]` → `Message[]` conversion currently happen?
|
||||
|
||||
**Proposed `before_agent_start` event:**
|
||||
- Fires once when user submits a prompt, before `agent_start`
|
||||
- Allows hooks to inject additional content that gets **persisted** to session
|
||||
- Injected content is visible in TUI (observability)
|
||||
- Does not bust prompt cache (appended after user message, not modifying system prompt)
|
||||
|
||||
**Key difference:**
|
||||
| Aspect | `context` | `before_agent_start` |
|
||||
|--------|-----------|---------------------|
|
||||
| When | Before each LLM call | Once per user prompt |
|
||||
| Persisted | No | Yes (as SystemMessage) |
|
||||
| TUI visible | No | Yes (collapsible) |
|
||||
| Cache impact | Can bust cache | Append-only, cache-safe |
|
||||
| Use case | Transient manipulation | Persistent context injection |
|
||||
|
||||
**Implementation (completed):**
|
||||
- Reuses `HookMessage` type (no new message type needed)
|
||||
- Handler returns `{ message: Pick<HookMessage, "customType" | "content" | "display" | "details"> }`
|
||||
- Message is appended to agent state AND persisted to session before `agent.prompt()` is called
|
||||
- Renders using existing `HookMessageComponent` (or custom renderer if registered)
|
||||
- [ ] How does it interact with compaction? (treated like user messages?)
|
||||
- [ ] Can hook return multiple messages or just one?
|
||||
|
||||
**Implementation sketch:**
|
||||
```typescript
|
||||
interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
userMessage: UserMessage; // The prompt user just submitted
|
||||
}
|
||||
|
||||
interface BeforeAgentStartResult {
|
||||
/** Additional context to inject (persisted as SystemMessage) */
|
||||
inject?: {
|
||||
label: string; // Shown in collapsed TUI state
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Export
|
||||
|
||||
- [ ] Add collapsible sidebar showing full tree structure
|
||||
- [ ] Allow selecting any node in tree to view that path
|
||||
- [ ] Add "reset to session leaf" button
|
||||
- [ ] Render full path (no compaction resolution needed)
|
||||
- [ ] Responsive: collapse sidebar on mobile
|
||||
|
||||
### UI Commands ✅
|
||||
|
||||
- [x] `/branch` - Creates new session file from current path (uses `createBranchedSession()`)
|
||||
- [x] `/tree` - In-session tree navigation via tree-selector component
|
||||
- Shows full tree structure with labels
|
||||
- Navigate between branches (moves leaf pointer)
|
||||
- Shows current position
|
||||
- Generates branch summaries when switching branches
|
||||
|
||||
### Tree Selector Improvements ✅
|
||||
|
||||
- [x] Active line highlight using `selectedBg` theme color
|
||||
- [x] Filter modes via `^O` (forward) / `Shift+^O` (backward):
|
||||
- `default`: hides label/custom entries
|
||||
- `no-tools`: default minus tool results
|
||||
- `user-only`: just user messages
|
||||
- `labeled-only`: just labeled entries
|
||||
- `all`: everything
|
||||
|
||||
### Documentation
|
||||
|
||||
Review and update all docs:
|
||||
|
||||
- [ ] `docs/hooks.md` - Major update for hook API:
|
||||
- `pi.send()` → `pi.sendMessage()` with new signature
|
||||
- New `pi.appendEntry()` for state persistence
|
||||
- New `pi.registerCommand()` for custom slash commands
|
||||
- New `pi.registerCustomMessageRenderer()` for custom TUI rendering
|
||||
- `HookCommandContext` interface and handler patterns
|
||||
- `HookMessage<T>` type
|
||||
- Updated event signatures (`SessionEventBase`, `before_compact`, etc.)
|
||||
- [ ] `docs/hooks-v2.md` - Review/merge or remove if obsolete
|
||||
- [ ] `docs/sdk.md` - Update for:
|
||||
- `HookMessage` and `isHookMessage()`
|
||||
- `Agent.prompt(AppMessage)` overload
|
||||
- Session v2 tree structure
|
||||
- SessionManager API changes
|
||||
- [ ] `docs/session.md` - Update for v2 tree structure, new entry types
|
||||
- [ ] `docs/custom-tools.md` - Check if hook changes affect custom tools
|
||||
- [ ] `docs/rpc.md` - Check if hook commands work in RPC mode
|
||||
- [ ] `docs/skills.md` - Review for any hook-related updates
|
||||
- [ ] `docs/extension-loading.md` - Review
|
||||
- [x] `docs/theme.md` - Added selectedBg, customMessageBg/Text/Label color tokens (50 total)
|
||||
- [ ] `README.md` - Update hook examples if any
|
||||
|
||||
### Examples
|
||||
|
||||
Review and update examples:
|
||||
|
||||
- [ ] `examples/hooks/` - Update existing, add new examples:
|
||||
- [ ] Review `custom-compaction.ts` for new API
|
||||
- [ ] Add `registerCommand()` example
|
||||
- [ ] Add `sendMessage()` example
|
||||
- [ ] Add `registerCustomMessageRenderer()` example
|
||||
- [ ] `examples/sdk/` - Update for new session/hook APIs
|
||||
- [ ] `examples/custom-tools/` - Review for compatibility
|
||||
|
||||
---
|
||||
|
||||
## Before Release
|
||||
|
||||
- [ ] Run full automated test suite: `npm test`
|
||||
- [ ] Manual testing of tree navigation and branch summarization
|
||||
- [ ] Verify compaction with file tracking works correctly
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All append methods return the new entry's ID
|
||||
- Migration rewrites file on first load if version < CURRENT_VERSION
|
||||
- Existing sessions become linear chains after migration (parentId = previous entry)
|
||||
- Tree features available immediately after migration
|
||||
- SessionHeader does NOT have id/parentId (it's metadata, not part of tree)
|
||||
- Session is append-only: entries cannot be modified or deleted, only branching changes the leaf pointer
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
# Session Tree Format
|
||||
|
||||
Analysis of switching from linear JSONL to tree-based session storage.
|
||||
|
||||
## Current Format (Linear)
|
||||
|
||||
```jsonl
|
||||
{"type":"session","id":"...","timestamp":"...","cwd":"..."}
|
||||
{"type":"message","timestamp":"...","message":{"role":"user",...}}
|
||||
{"type":"message","timestamp":"...","message":{"role":"assistant",...}}
|
||||
{"type":"compaction","timestamp":"...","summary":"...","firstKeptEntryIndex":2,"tokensBefore":50000}
|
||||
{"type":"message","timestamp":"...","message":{"role":"user",...}}
|
||||
```
|
||||
|
||||
Context is built by scanning linearly, applying compaction ranges.
|
||||
|
||||
## Proposed Format (Tree)
|
||||
|
||||
Each entry has a `uuid` and `parentUuid` field (null for root). Session header includes `version` for future migrations:
|
||||
|
||||
```jsonl
|
||||
{"type":"session","version":2,"uuid":"a1b2c3","parentUuid":null,"id":"...","cwd":"..."}
|
||||
{"type":"message","uuid":"d4e5f6","parentUuid":"a1b2c3","message":{"role":"user",...}}
|
||||
{"type":"message","uuid":"g7h8i9","parentUuid":"d4e5f6","message":{"role":"assistant",...}}
|
||||
{"type":"message","uuid":"j0k1l2","parentUuid":"g7h8i9","message":{"role":"user",...}}
|
||||
{"type":"message","uuid":"m3n4o5","parentUuid":"j0k1l2","message":{"role":"assistant",...}}
|
||||
```
|
||||
|
||||
Version history:
|
||||
- **v1** (implicit): Linear format, no uuid/parentUuid
|
||||
- **v2**: Tree format with uuid/parentUuid
|
||||
|
||||
The **last entry** is always the current leaf. Context = walk from leaf to root via `parentUuid`.
|
||||
|
||||
Using UUIDs (like Claude Code does) instead of indices because:
|
||||
- No remapping needed when branching to new file
|
||||
- Robust to entry deletion/reordering
|
||||
- Orphan references are detectable
|
||||
- ~30 extra bytes per entry is negligible for text-heavy sessions
|
||||
|
||||
### Branching
|
||||
|
||||
Branch from entry `g7h8i9` (after first assistant response):
|
||||
|
||||
```jsonl
|
||||
... entries unchanged ...
|
||||
{"type":"message","uuid":"p6q7r8","parentUuid":"g7h8i9","message":{"role":"user",...}}
|
||||
{"type":"message","uuid":"s9t0u1","parentUuid":"p6q7r8","message":{"role":"assistant",...}}
|
||||
```
|
||||
|
||||
Walking s9t0u1→p6q7r8→g7h8i9→d4e5f6→a1b2c3 gives the branched context.
|
||||
|
||||
The old path (j0k1l2, m3n4o5) remains in the file but is not in the current context.
|
||||
|
||||
### Visual
|
||||
|
||||
```
|
||||
[a1b2:session]
|
||||
│
|
||||
[d4e5:user "hello"]
|
||||
│
|
||||
[g7h8:assistant "hi"]
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
[j0k1:user A] [p6q7:user B] ← branch point
|
||||
│ │
|
||||
[m3n4:asst A] [s9t0:asst B] ← current leaf
|
||||
│
|
||||
(old path)
|
||||
```
|
||||
|
||||
## Context Building
|
||||
|
||||
```typescript
|
||||
function buildContext(entries: SessionEntry[]): AppMessage[] {
|
||||
// Build UUID -> entry map
|
||||
const byUuid = new Map(entries.map(e => [e.uuid, e]));
|
||||
|
||||
// Start from last entry (current leaf)
|
||||
let current: SessionEntry | undefined = entries[entries.length - 1];
|
||||
|
||||
// Walk to root, collecting messages
|
||||
const path: SessionEntry[] = [];
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentUuid ? byUuid.get(current.parentUuid) : undefined;
|
||||
}
|
||||
|
||||
// Extract messages, apply compaction summaries
|
||||
return pathToMessages(path);
|
||||
}
|
||||
```
|
||||
|
||||
Complexity: O(n) to build map, O(depth) to walk. Total O(n), but walk is fast.
|
||||
|
||||
## Consequences for Stacking
|
||||
|
||||
### Current Approach (hooks-v2.md)
|
||||
|
||||
Stacking uses `stack_pop` entries with complex range overlap rules:
|
||||
|
||||
```typescript
|
||||
interface StackPopEntry {
|
||||
type: "stack_pop";
|
||||
backToIndex: number;
|
||||
summary: string;
|
||||
prePopSummary?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Context building requires tracking ranges, IDs, "later wins" logic.
|
||||
|
||||
### Tree Approach
|
||||
|
||||
Stacking becomes trivial branching:
|
||||
|
||||
```jsonl
|
||||
... conversation entries ...
|
||||
{"type":"stack_summary","uuid":"x1y2z3","parentUuid":"g7h8i9","summary":"Work done after this point"}
|
||||
```
|
||||
|
||||
To "pop" to entry `g7h8i9`:
|
||||
1. Generate summary of entries after `g7h8i9`
|
||||
2. Append summary entry with `parentUuid: "g7h8i9"`
|
||||
|
||||
Context walk follows parentUuid chain. Abandoned entries are not traversed.
|
||||
|
||||
**No range tracking. No overlap rules. No "later wins" logic.**
|
||||
|
||||
### Multiple Pops
|
||||
|
||||
```
|
||||
[a]─[b]─[c]─[d]─[e]─[f]─[g]─[h]
|
||||
│
|
||||
└─[i:summary]─[j]─[k]─[l]
|
||||
│
|
||||
└─[m:summary]─[n:current]
|
||||
```
|
||||
|
||||
Each pop just creates a new branch. Context: n→m→k→j→i→c→b→a.
|
||||
|
||||
## Consequences for Compaction
|
||||
|
||||
### Current Approach
|
||||
|
||||
Compaction stores `firstKeptEntryIndex` (an index) and requires careful handling when stacking crosses compaction boundaries.
|
||||
|
||||
### Tree Approach
|
||||
|
||||
Compaction is just another entry in the linear chain, not a branch. Only change: `firstKeptEntryIndex` → `firstKeptEntryUuid`.
|
||||
|
||||
```
|
||||
root → m1 → m2 → m3 → m4 → m5 → m6 → m7 → m8 → m9 → m10 → compaction
|
||||
```
|
||||
|
||||
```jsonl
|
||||
{"type":"compaction","uuid":"c1","parentUuid":"m10","summary":"...","firstKeptEntryUuid":"m6","tokensBefore":50000}
|
||||
```
|
||||
|
||||
Context building:
|
||||
1. Walk from leaf (compaction) to root
|
||||
2. See compaction entry → note `firstKeptEntryUuid: "m6"`
|
||||
3. Continue walking: m10, m9, m8, m7, m6 ← stop here
|
||||
4. Everything before m6 is replaced by summary
|
||||
5. Result: `[summary, m6, m7, m8, m9, m10]`
|
||||
|
||||
**Tree is for branching (stacking, alternative paths). Compaction is just a marker in the linear chain.**
|
||||
|
||||
### Compaction + Stacking
|
||||
|
||||
Stacking creates a branch, compaction is inline on each branch:
|
||||
|
||||
```
|
||||
[root]─[m1]─[m2]─[m3]─[m4]─[m5]─[compaction1]─[m6]─[m7]─[m8]
|
||||
│
|
||||
└─[stack_summary]─[m9]─[m10]─[compaction2]─[m11:current]
|
||||
```
|
||||
|
||||
Each branch has its own compaction history. Context walks the current branch only.
|
||||
|
||||
## Consequences for API
|
||||
|
||||
### SessionManager Changes
|
||||
|
||||
```typescript
|
||||
interface SessionEntry {
|
||||
type: string;
|
||||
uuid: string; // NEW: unique identifier
|
||||
parentUuid: string | null; // NEW: null for root
|
||||
timestamp?: string;
|
||||
// ... type-specific fields
|
||||
}
|
||||
|
||||
class SessionManager {
|
||||
// NEW: Get current leaf entry
|
||||
getCurrentLeaf(): SessionEntry;
|
||||
|
||||
// NEW: Walk from entry to root
|
||||
getPath(fromUuid?: string): SessionEntry[];
|
||||
|
||||
// NEW: Get entry by UUID
|
||||
getEntry(uuid: string): SessionEntry | undefined;
|
||||
|
||||
// CHANGED: Uses tree walk instead of linear scan
|
||||
buildSessionContext(): SessionContext;
|
||||
|
||||
// NEW: Create branch point
|
||||
branch(parentUuid: string): string; // returns new entry's uuid
|
||||
|
||||
// NEW: Create branch with summary of abandoned subtree
|
||||
branchWithSummary(parentUuid: string, summary: string): string;
|
||||
|
||||
// CHANGED: Simpler, just creates summary node
|
||||
saveCompaction(entry: CompactionEntry): void;
|
||||
|
||||
// CHANGED: Now requires parentUuid (uses current leaf if omitted)
|
||||
saveMessage(message: AppMessage, parentUuid?: string): void;
|
||||
saveEntry(entry: SessionEntry): void;
|
||||
}
|
||||
```
|
||||
|
||||
### AgentSession Changes
|
||||
|
||||
```typescript
|
||||
class AgentSession {
|
||||
// CHANGED: Uses tree-based branching
|
||||
async branch(entryUuid: string): Promise<BranchResult>;
|
||||
|
||||
// NEW: Branch in current session (no new file)
|
||||
async branchInPlace(entryUuid: string, options?: {
|
||||
summarize?: boolean; // Generate summary of abandoned subtree
|
||||
}): Promise<void>;
|
||||
|
||||
// NEW: Get tree structure for visualization
|
||||
getSessionTree(): SessionTree;
|
||||
|
||||
// CHANGED: Simpler implementation
|
||||
async compact(): Promise<CompactionResult>;
|
||||
}
|
||||
|
||||
interface BranchResult {
|
||||
selectedText: string;
|
||||
cancelled: boolean;
|
||||
newSessionFile?: string; // If branching to new file
|
||||
inPlace: boolean; // If branched in current file
|
||||
}
|
||||
```
|
||||
|
||||
### Hook API Changes
|
||||
|
||||
```typescript
|
||||
interface HookEventContext {
|
||||
// NEW: Tree-aware entry access
|
||||
entries: readonly SessionEntry[];
|
||||
currentPath: readonly SessionEntry[]; // Entries from root to current leaf
|
||||
|
||||
// NEW: Branch without creating new file
|
||||
branchInPlace(parentUuid: string, summary?: string): Promise<void>;
|
||||
|
||||
// Existing
|
||||
saveEntry(entry: SessionEntry): Promise<void>;
|
||||
rebuildContext(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
## New Features Enabled
|
||||
|
||||
### 1. In-Place Branching
|
||||
|
||||
Currently, `/branch` always creates a new session file. With tree format:
|
||||
|
||||
```
|
||||
/branch → Create new session file (current behavior)
|
||||
/branch-here → Branch in current file, optionally with summary
|
||||
```
|
||||
|
||||
Use case: Quick "let me try something else" without file proliferation.
|
||||
|
||||
### 2. Branch History Navigation
|
||||
|
||||
```
|
||||
/branches → List all branches in current session
|
||||
/switch <uuid> → Switch to branch at entry
|
||||
```
|
||||
|
||||
The session file contains full history. UI can visualize the tree.
|
||||
|
||||
### 3. Simpler Stacking
|
||||
|
||||
No hooks needed for basic stacking:
|
||||
|
||||
```
|
||||
/pop → Branch to previous user message with auto-summary
|
||||
/pop <uuid> → Branch to specific entry with auto-summary
|
||||
```
|
||||
|
||||
Core functionality, not hook-dependent.
|
||||
|
||||
### 4. Subtree Export
|
||||
|
||||
```
|
||||
/export-branch <uuid> → Export just the subtree from entry
|
||||
```
|
||||
|
||||
Useful for sharing specific conversation paths. No index remapping needed since UUIDs are stable.
|
||||
|
||||
### 5. Merge/Cherry-pick (Future)
|
||||
|
||||
With tree structure, could support:
|
||||
|
||||
```
|
||||
/cherry-pick <uuid> → Copy entry's message to current branch
|
||||
/merge <uuid> → Merge branch into current
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
### Strategy: Migrate on Load + Rewrite
|
||||
|
||||
When loading a session, check if migration is needed. If so, migrate in memory and rewrite the file. This is transparent to users and only happens once per session file.
|
||||
|
||||
```typescript
|
||||
const CURRENT_VERSION = 2;
|
||||
|
||||
function loadSession(path: string): SessionEntry[] {
|
||||
const content = readFileSync(path, 'utf8');
|
||||
const entries = parseEntries(content);
|
||||
|
||||
const header = entries.find(e => e.type === 'session');
|
||||
const version = header?.version ?? 1;
|
||||
|
||||
if (version < CURRENT_VERSION) {
|
||||
migrateEntries(entries, version);
|
||||
writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function migrateEntries(entries: SessionEntry[], fromVersion: number): void {
|
||||
if (fromVersion < 2) {
|
||||
// v1 → v2: Add uuid/parentUuid, convert firstKeptEntryIndex
|
||||
const uuids: string[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const uuid = generateUuid();
|
||||
uuids.push(uuid);
|
||||
|
||||
entry.uuid = uuid;
|
||||
entry.parentUuid = i === 0 ? null : uuids[i - 1];
|
||||
|
||||
// Update session header version
|
||||
if (entry.type === 'session') {
|
||||
entry.version = CURRENT_VERSION;
|
||||
}
|
||||
|
||||
// Convert compaction index to UUID
|
||||
if (entry.type === 'compaction' && 'firstKeptEntryIndex' in entry) {
|
||||
entry.firstKeptEntryUuid = uuids[entry.firstKeptEntryIndex];
|
||||
delete entry.firstKeptEntryIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Future migrations: if (fromVersion < 3) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### What Gets Migrated
|
||||
|
||||
| v1 Field | v2 Field |
|
||||
|----------|----------|
|
||||
| (none) | `uuid` (generated) |
|
||||
| (none) | `parentUuid` (previous entry's uuid, null for root) |
|
||||
| (none on session) | `version: 2` |
|
||||
| `firstKeptEntryIndex` | `firstKeptEntryUuid` |
|
||||
|
||||
Migrated sessions work exactly as before (linear path). Tree features become available.
|
||||
|
||||
### API Compatibility
|
||||
|
||||
- `buildSessionContext()` returns same structure
|
||||
- `branch()` still works, just uses UUIDs
|
||||
- Existing hooks continue to work
|
||||
- Old sessions auto-migrate on first load
|
||||
|
||||
## Complexity Analysis
|
||||
|
||||
| Operation | Linear | Tree |
|
||||
|-----------|--------|------|
|
||||
| Append message | O(1) | O(1) |
|
||||
| Build context | O(n) | O(n) map + O(depth) walk |
|
||||
| Branch to new file | O(n) copy | O(path) copy, no remapping |
|
||||
| Find entry by UUID | O(n) | O(1) with map |
|
||||
| Compaction | O(n) | O(depth) |
|
||||
|
||||
Tree with UUIDs is comparable or better. The UUID map can be cached.
|
||||
|
||||
## File Size
|
||||
|
||||
Tree format adds ~50 bytes per entry (`"uuid":"...","parentUuid":"..."`, 36 chars each). For 1000-entry session: ~50KB overhead. Negligible for text-heavy sessions.
|
||||
|
||||
Abandoned branches remain in file but don't affect context building performance.
|
||||
|
||||
## Example: Full Session with Branching
|
||||
|
||||
```jsonl
|
||||
{"type":"session","version":2,"uuid":"ses1","parentUuid":null,"id":"abc","cwd":"/project"}
|
||||
{"type":"message","uuid":"m1","parentUuid":"ses1","message":{"role":"user","content":"Build a CLI"}}
|
||||
{"type":"message","uuid":"m2","parentUuid":"m1","message":{"role":"assistant","content":"I'll create..."}}
|
||||
{"type":"message","uuid":"m3","parentUuid":"m2","message":{"role":"user","content":"Add --verbose flag"}}
|
||||
{"type":"message","uuid":"m4","parentUuid":"m3","message":{"role":"assistant","content":"Here's the flag..."}}
|
||||
{"type":"message","uuid":"m5","parentUuid":"m4","message":{"role":"user","content":"Actually use Python"}}
|
||||
{"type":"message","uuid":"m6","parentUuid":"m5","message":{"role":"assistant","content":"Converting to Python..."}}
|
||||
{"type":"branch_summary","uuid":"bs1","parentUuid":"m2","summary":"Attempted Node.js CLI with --verbose flag"}
|
||||
{"type":"message","uuid":"m7","parentUuid":"bs1","message":{"role":"user","content":"Use Rust instead"}}
|
||||
{"type":"message","uuid":"m8","parentUuid":"m7","message":{"role":"assistant","content":"Creating Rust CLI..."}}
|
||||
```
|
||||
|
||||
Context path: m8→m7→bs1→m2→m1→ses1
|
||||
|
||||
Result:
|
||||
1. User: "Build a CLI"
|
||||
2. Assistant: "I'll create..."
|
||||
3. Summary: "Attempted Node.js CLI with --verbose flag"
|
||||
4. User: "Use Rust instead"
|
||||
5. Assistant: "Creating Rust CLI..."
|
||||
|
||||
Entries m3-m6 (the Node.js/Python path) are preserved but not in context.
|
||||
|
||||
## Prior Art
|
||||
|
||||
Claude Code uses the same approach:
|
||||
- `uuid` field on each entry
|
||||
- `parentUuid` links to parent (null for root)
|
||||
- `leafUuid` in summary entries to track conversation endpoints
|
||||
- Separate files for sidechains (`isSidechain: true`)
|
||||
|
||||
## Recommendation
|
||||
|
||||
The tree format with UUIDs:
|
||||
- Simplifies stacking (no range overlap logic)
|
||||
- Simplifies compaction (no boundary crossing)
|
||||
- Enables in-place branching
|
||||
- Enables branch visualization/navigation
|
||||
- No index remapping on branch-to-file
|
||||
- Maintains backward compatibility
|
||||
- Validated by Claude Code's implementation
|
||||
|
||||
**Recommend implementing for v2 of hooks/session system.**
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Session File Format
|
||||
|
||||
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field.
|
||||
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Session entries form a tree structure via `id`/`parentId` fields, enabling in-place branching without creating new files.
|
||||
|
||||
## File Location
|
||||
|
||||
|
|
@ -10,47 +10,66 @@ Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with
|
|||
|
||||
Where `<path>` is the working directory with `/` replaced by `-`.
|
||||
|
||||
## Session Version
|
||||
|
||||
Sessions have a version field in the header:
|
||||
|
||||
- **Version 1**: Linear entry sequence (legacy, auto-migrated on load)
|
||||
- **Version 2**: Tree structure with `id`/`parentId` linking
|
||||
|
||||
Existing v1 sessions are automatically migrated to v2 when loaded.
|
||||
|
||||
## Type Definitions
|
||||
|
||||
- [`src/session-manager.ts`](../src/session-manager.ts) - Session entry types (`SessionHeader`, `SessionMessageEntry`, etc.)
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `ThinkingLevel`
|
||||
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Session entry types
|
||||
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `Attachment`, `ThinkingLevel`
|
||||
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall`
|
||||
|
||||
## Entry Base
|
||||
|
||||
All entries (except `SessionHeader`) extend `SessionEntryBase`:
|
||||
|
||||
```typescript
|
||||
interface SessionEntryBase {
|
||||
type: string;
|
||||
id: string; // 8-char hex ID
|
||||
parentId: string | null; // Parent entry ID (null for first entry)
|
||||
timestamp: string; // ISO timestamp
|
||||
}
|
||||
```
|
||||
|
||||
## Entry Types
|
||||
|
||||
### SessionHeader
|
||||
|
||||
First line of the file. Defines session metadata.
|
||||
First line of the file. Metadata only, not part of the tree (no `id`/`parentId`).
|
||||
|
||||
```json
|
||||
{"type":"session","id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","provider":"anthropic","modelId":"claude-sonnet-4-5","thinkingLevel":"off"}
|
||||
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
|
||||
```
|
||||
|
||||
For branched sessions, includes the source session path:
|
||||
For branched sessions (created via `/branch` command):
|
||||
|
||||
```json
|
||||
{"type":"session","id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","provider":"anthropic","modelId":"claude-sonnet-4-5","thinkingLevel":"off","branchedFrom":"/path/to/original/session.jsonl"}
|
||||
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","branchedFrom":"/path/to/original/session.jsonl"}
|
||||
```
|
||||
|
||||
### SessionMessageEntry
|
||||
|
||||
A message in the conversation. The `message` field contains an `AppMessage` (see [rpc.md](./rpc.md#message-types)).
|
||||
A message in the conversation. The `message` field contains an `AgentMessage`.
|
||||
|
||||
```json
|
||||
{"type":"message","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234567890}}
|
||||
{"type":"message","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop","timestamp":1733234567891}}
|
||||
{"type":"message","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":1733234567900}}
|
||||
{"type":"message","timestamp":"2024-12-03T14:00:04.000Z","message":{"role":"bashExecution","command":"ls -la","output":"total 48\n...","exitCode":0,"cancelled":false,"truncated":false,"timestamp":1733234567950}}
|
||||
{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello"}}
|
||||
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}}
|
||||
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false}}
|
||||
```
|
||||
|
||||
The `bashExecution` role is a custom message type for user-executed bash commands (via `!` in TUI or `bash` RPC command). See [rpc.md](./rpc.md#bashexecutionmessage) for the full schema.
|
||||
|
||||
### ModelChangeEntry
|
||||
|
||||
Emitted when the user switches models mid-session.
|
||||
|
||||
```json
|
||||
{"type":"model_change","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
|
||||
{"type":"model_change","id":"d4e5f6g7","parentId":"c3d4e5f6","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}
|
||||
```
|
||||
|
||||
### ThinkingLevelChangeEntry
|
||||
|
|
@ -58,9 +77,92 @@ Emitted when the user switches models mid-session.
|
|||
Emitted when the user changes the thinking/reasoning level.
|
||||
|
||||
```json
|
||||
{"type":"thinking_level_change","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
|
||||
{"type":"thinking_level_change","id":"e5f6g7h8","parentId":"d4e5f6g7","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}
|
||||
```
|
||||
|
||||
### CompactionEntry
|
||||
|
||||
Created when context is compacted. Stores a summary of earlier messages.
|
||||
|
||||
```json
|
||||
{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}
|
||||
```
|
||||
|
||||
Optional fields:
|
||||
- `details`: Compaction-implementation specific data (e.g., file operations for default implementation, or custom data for custom hook implementations)
|
||||
- `fromHook`: `true` if generated by a hook, `false`/`undefined` if pi-generated
|
||||
|
||||
### BranchSummaryEntry
|
||||
|
||||
Created when switching branches via `/tree` with an LLM generated summary of the left branch up to the common ancestor. Captures context from the abandoned path.
|
||||
|
||||
```json
|
||||
{"type":"branch_summary","id":"g7h8i9j0","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:15:00.000Z","fromId":"f6g7h8i9","summary":"Branch explored approach A..."}
|
||||
```
|
||||
|
||||
Optional fields:
|
||||
- `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`) for default implementation, arbitrary for custom implementation
|
||||
- `fromHook`: `true` if generated by a hook
|
||||
|
||||
### CustomEntry
|
||||
|
||||
Hook state persistence. Does NOT participate in LLM context.
|
||||
|
||||
```json
|
||||
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-hook","data":{"count":42}}
|
||||
```
|
||||
|
||||
Use `customType` to identify your hook's entries on reload.
|
||||
|
||||
### CustomMessageEntry
|
||||
|
||||
Hook-injected messages that DO participate in LLM context.
|
||||
|
||||
```json
|
||||
{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-hook","content":"Injected context...","display":true}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `content`: String or `(TextContent | ImageContent)[]` (same as UserMessage)
|
||||
- `display`: `true` = show in TUI with purple styling, `false` = hidden
|
||||
- `details`: Optional hook-specific metadata (not sent to LLM)
|
||||
|
||||
### LabelEntry
|
||||
|
||||
User-defined bookmark/marker on an entry.
|
||||
|
||||
```json
|
||||
{"type":"label","id":"j0k1l2m3","parentId":"i9j0k1l2","timestamp":"2024-12-03T14:30:00.000Z","targetId":"a1b2c3d4","label":"checkpoint-1"}
|
||||
```
|
||||
|
||||
Set `label` to `undefined` to clear a label.
|
||||
|
||||
## Tree Structure
|
||||
|
||||
Entries form a tree:
|
||||
- First entry has `parentId: null`
|
||||
- Each subsequent entry points to its parent via `parentId`
|
||||
- Branching creates new children from an earlier entry
|
||||
- The "leaf" is the current position in the tree
|
||||
|
||||
```
|
||||
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
|
||||
│
|
||||
└─ [branch_summary] ─── [user msg] ← alternate branch
|
||||
```
|
||||
|
||||
## Context Building
|
||||
|
||||
`buildSessionContext()` walks from the current leaf to the root, producing the message list for the LLM:
|
||||
|
||||
1. Collects all entries on the path
|
||||
2. Extracts current model and thinking level settings
|
||||
3. If a `CompactionEntry` is on the path:
|
||||
- Emits the summary first
|
||||
- Then messages from `firstKeptEntryId` to compaction
|
||||
- Then messages after compaction
|
||||
4. Converts `BranchSummaryEntry` and `CustomMessageEntry` to appropriate message formats
|
||||
|
||||
## Parsing Example
|
||||
|
||||
```typescript
|
||||
|
|
@ -70,20 +172,69 @@ const lines = readFileSync("session.jsonl", "utf8").trim().split("\n");
|
|||
|
||||
for (const line of lines) {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
|
||||
switch (entry.type) {
|
||||
case "session":
|
||||
console.log(`Session: ${entry.id}, Model: ${entry.provider}/${entry.modelId}`);
|
||||
console.log(`Session v${entry.version ?? 1}: ${entry.id}`);
|
||||
break;
|
||||
case "message":
|
||||
console.log(`${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
|
||||
console.log(`[${entry.id}] ${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
|
||||
break;
|
||||
case "compaction":
|
||||
console.log(`[${entry.id}] Compaction: ${entry.tokensBefore} tokens summarized`);
|
||||
break;
|
||||
case "branch_summary":
|
||||
console.log(`[${entry.id}] Branch from ${entry.fromId}`);
|
||||
break;
|
||||
case "custom":
|
||||
console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);
|
||||
break;
|
||||
case "custom_message":
|
||||
console.log(`[${entry.id}] Hook message (${entry.customType}): ${entry.content}`);
|
||||
break;
|
||||
case "label":
|
||||
console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`);
|
||||
break;
|
||||
case "model_change":
|
||||
console.log(`Switched to: ${entry.provider}/${entry.modelId}`);
|
||||
console.log(`[${entry.id}] Model: ${entry.provider}/${entry.modelId}`);
|
||||
break;
|
||||
case "thinking_level_change":
|
||||
console.log(`Thinking: ${entry.thinkingLevel}`);
|
||||
console.log(`[${entry.id}] Thinking: ${entry.thinkingLevel}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SessionManager API
|
||||
|
||||
Key methods for working with sessions programmatically:
|
||||
|
||||
### Creation
|
||||
- `SessionManager.create(cwd, sessionDir?)` - New session
|
||||
- `SessionManager.open(path, sessionDir?)` - Open existing
|
||||
- `SessionManager.continueRecent(cwd, sessionDir?)` - Continue most recent or create new
|
||||
- `SessionManager.inMemory(cwd?)` - No file persistence
|
||||
|
||||
### Appending (all return entry ID)
|
||||
- `appendMessage(message)` - Add message
|
||||
- `appendThinkingLevelChange(level)` - Record thinking change
|
||||
- `appendModelChange(provider, modelId)` - Record model change
|
||||
- `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction
|
||||
- `appendCustomEntry(customType, data?)` - Hook state (not in context)
|
||||
- `appendCustomMessageEntry(customType, content, display, details?)` - Hook message (in context)
|
||||
- `appendLabelChange(targetId, label)` - Set/clear label
|
||||
|
||||
### Tree Navigation
|
||||
- `getLeafId()` - Current position
|
||||
- `getEntry(id)` - Get entry by ID
|
||||
- `getPath(fromId?)` - Walk from entry to root
|
||||
- `getTree()` - Get full tree structure
|
||||
- `getChildren(parentId)` - Get direct children
|
||||
- `getLabel(id)` - Get label for entry
|
||||
- `branch(entryId)` - Move leaf to earlier entry
|
||||
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
|
||||
|
||||
### Context
|
||||
- `buildSessionContext()` - Get messages for LLM
|
||||
- `getEntries()` - All entries (excluding header)
|
||||
- `getHeader()` - Session metadata
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> pi can create skills. Ask it to build one for your use case.
|
||||
|
||||
# Skills
|
||||
|
||||
Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> pi can create themes. Ask it to build one for your use case.
|
||||
|
||||
# Pi Coding Agent Themes
|
||||
|
||||
Themes allow you to customize the colors used throughout the coding agent TUI.
|
||||
|
|
@ -20,13 +22,18 @@ Every theme must define all color tokens. There are no optional colors.
|
|||
| `muted` | Secondary/dimmed text | Metadata, descriptions, output |
|
||||
| `dim` | Very dimmed text | Less important info, placeholders |
|
||||
| `text` | Default text color | Main content (usually `""`) |
|
||||
| `thinkingText` | Thinking block text | Assistant reasoning traces |
|
||||
|
||||
### Backgrounds & Content Text (7 colors)
|
||||
### Backgrounds & Content Text (11 colors)
|
||||
|
||||
| Token | Purpose |
|
||||
|-------|---------|
|
||||
| `selectedBg` | Selected/active line background (e.g., tree selector) |
|
||||
| `userMessageBg` | User message background |
|
||||
| `userMessageText` | User message text color |
|
||||
| `customMessageBg` | Hook custom message background |
|
||||
| `customMessageText` | Hook custom message text color |
|
||||
| `customMessageLabel` | Hook custom message label/type text |
|
||||
| `toolPendingBg` | Tool execution box (pending state) |
|
||||
| `toolSuccessBg` | Tool execution box (success state) |
|
||||
| `toolErrorBg` | Tool execution box (error state) |
|
||||
|
|
@ -95,7 +102,7 @@ These create a visual hierarchy: off → minimal → low → medium → high →
|
|||
|-------|---------|
|
||||
| `bashMode` | Editor border color when in bash mode (! prefix) |
|
||||
|
||||
**Total: 46 color tokens** (all required)
|
||||
**Total: 50 color tokens** (all required)
|
||||
|
||||
## Theme Format
|
||||
|
||||
|
|
@ -113,6 +120,7 @@ Themes are defined in JSON files with the following structure:
|
|||
"colors": {
|
||||
"accent": "blue",
|
||||
"muted": "gray",
|
||||
"thinkingText": "gray",
|
||||
"text": "",
|
||||
...
|
||||
}
|
||||
|
|
|
|||
197
packages/coding-agent/docs/tree.md
Normal file
197
packages/coding-agent/docs/tree.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Session Tree Navigation
|
||||
|
||||
The `/tree` command provides tree-based navigation of the session history.
|
||||
|
||||
## Overview
|
||||
|
||||
Sessions are stored as trees where each entry has an `id` and `parentId`. The "leaf" pointer tracks the current position. `/tree` lets you navigate to any point and optionally summarize the branch you're leaving.
|
||||
|
||||
### Comparison with `/branch`
|
||||
|
||||
| Feature | `/branch` | `/tree` |
|
||||
|---------|-----------|---------|
|
||||
| View | Flat list of user messages | Full tree structure |
|
||||
| Action | Extracts path to **new session file** | Changes leaf in **same session** |
|
||||
| Summary | Never | Optional (user prompted) |
|
||||
| Events | `session_before_branch` / `session_branch` | `session_before_tree` / `session_tree` |
|
||||
|
||||
## Tree UI
|
||||
|
||||
```
|
||||
├─ user: "Hello, can you help..."
|
||||
│ └─ assistant: "Of course! I can..."
|
||||
│ ├─ user: "Let's try approach A..."
|
||||
│ │ └─ assistant: "For approach A..."
|
||||
│ │ └─ [compaction: 12k tokens]
|
||||
│ │ └─ user: "That worked..." ← active
|
||||
│ └─ user: "Actually, approach B..."
|
||||
│ └─ assistant: "For approach B..."
|
||||
```
|
||||
|
||||
### Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| ↑/↓ | Navigate (depth-first order) |
|
||||
| Enter | Select node |
|
||||
| Escape/Ctrl+C | Cancel |
|
||||
| Ctrl+U | Toggle: user messages only |
|
||||
| Ctrl+O | Toggle: show all (including custom/label entries) |
|
||||
|
||||
### Display
|
||||
|
||||
- Height: half terminal height
|
||||
- Current leaf marked with `← active`
|
||||
- Labels shown inline: `[label-name]`
|
||||
- Default filter hides `label` and `custom` entries (shown in Ctrl+O mode)
|
||||
- Children sorted by timestamp (oldest first)
|
||||
|
||||
## Selection Behavior
|
||||
|
||||
### User Message or Custom Message
|
||||
1. Leaf set to **parent** of selected node (or `null` if root)
|
||||
2. Message text placed in **editor** for re-submission
|
||||
3. User edits and submits, creating a new branch
|
||||
|
||||
### Non-User Message (assistant, compaction, etc.)
|
||||
1. Leaf set to **selected node**
|
||||
2. Editor stays empty
|
||||
3. User continues from that point
|
||||
|
||||
### Selecting Root User Message
|
||||
If user selects the very first message (has no parent):
|
||||
1. Leaf reset to `null` (empty conversation)
|
||||
2. Message text placed in editor
|
||||
3. User effectively restarts from scratch
|
||||
|
||||
## Branch Summarization
|
||||
|
||||
When switching, user is prompted: "Summarize the branch you're leaving?"
|
||||
|
||||
### What Gets Summarized
|
||||
|
||||
Path from old leaf back to common ancestor with target:
|
||||
|
||||
```
|
||||
A → B → C → D → E → F ← old leaf
|
||||
↘ G → H ← target
|
||||
```
|
||||
|
||||
Abandoned path: D → E → F (summarized)
|
||||
|
||||
Summarization stops at:
|
||||
1. Common ancestor (always)
|
||||
2. Compaction node (if encountered first)
|
||||
|
||||
### Summary Storage
|
||||
|
||||
Stored as `BranchSummaryEntry`:
|
||||
|
||||
```typescript
|
||||
interface BranchSummaryEntry {
|
||||
type: "branch_summary";
|
||||
id: string;
|
||||
parentId: string; // New leaf position
|
||||
timestamp: string;
|
||||
fromId: string; // Old leaf we abandoned
|
||||
summary: string; // LLM-generated summary
|
||||
details?: unknown; // Optional hook data
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### AgentSession.navigateTree()
|
||||
|
||||
```typescript
|
||||
async navigateTree(
|
||||
targetId: string,
|
||||
options?: { summarize?: boolean; customInstructions?: string }
|
||||
): Promise<{ editorText?: string; cancelled: boolean }>
|
||||
```
|
||||
|
||||
Flow:
|
||||
1. Validate target, check no-op (target === current leaf)
|
||||
2. Find common ancestor between old leaf and target
|
||||
3. Collect entries to summarize (if requested)
|
||||
4. Fire `session_before_tree` event (hook can cancel or provide summary)
|
||||
5. Run default summarizer if needed
|
||||
6. Switch leaf via `branch()` or `branchWithSummary()`
|
||||
7. Update agent: `agent.replaceMessages(sessionManager.buildSessionContext().messages)`
|
||||
8. Fire `session_tree` event
|
||||
9. Notify custom tools via session event
|
||||
10. Return result with `editorText` if user message was selected
|
||||
|
||||
### SessionManager
|
||||
|
||||
- `getLeafUuid(): string | null` - Current leaf (null if empty)
|
||||
- `resetLeaf(): void` - Set leaf to null (for root user message navigation)
|
||||
- `getTree(): SessionTreeNode[]` - Full tree with children sorted by timestamp
|
||||
- `branch(id)` - Change leaf pointer
|
||||
- `branchWithSummary(id, summary)` - Change leaf and create summary entry
|
||||
|
||||
### InteractiveMode
|
||||
|
||||
`/tree` command shows `TreeSelectorComponent`, then:
|
||||
1. Prompt for summarization
|
||||
2. Call `session.navigateTree()`
|
||||
3. Clear and re-render chat
|
||||
4. Set editor text if applicable
|
||||
|
||||
## Hook Events
|
||||
|
||||
### `session_before_tree`
|
||||
|
||||
```typescript
|
||||
interface TreePreparation {
|
||||
targetId: string;
|
||||
oldLeafId: string | null;
|
||||
commonAncestorId: string | null;
|
||||
entriesToSummarize: SessionEntry[];
|
||||
userWantsSummary: boolean;
|
||||
}
|
||||
|
||||
interface SessionBeforeTreeEvent {
|
||||
type: "session_before_tree";
|
||||
preparation: TreePreparation;
|
||||
model: Model;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
interface SessionBeforeTreeResult {
|
||||
cancel?: boolean;
|
||||
summary?: { summary: string; details?: unknown };
|
||||
}
|
||||
```
|
||||
|
||||
### `session_tree`
|
||||
|
||||
```typescript
|
||||
interface SessionTreeEvent {
|
||||
type: "session_tree";
|
||||
newLeafId: string | null;
|
||||
oldLeafId: string | null;
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Custom Summarizer
|
||||
|
||||
```typescript
|
||||
export default function(pi: HookAPI) {
|
||||
pi.on("session_before_tree", async (event, ctx) => {
|
||||
if (!event.preparation.userWantsSummary) return;
|
||||
if (event.preparation.entriesToSummarize.length === 0) return;
|
||||
|
||||
const summary = await myCustomSummarizer(event.preparation.entriesToSummarize);
|
||||
return { summary: { summary, details: { custom: true } } };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Summarization failure: cancels navigation, shows error
|
||||
- User abort (Escape): cancels navigation
|
||||
- Hook returns `cancel: true`: cancels navigation silently
|
||||
343
packages/coding-agent/docs/tui.md
Normal file
343
packages/coding-agent/docs/tui.md
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
> pi can create TUI components. Ask it to build one for your use case.
|
||||
|
||||
# TUI Components
|
||||
|
||||
Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.
|
||||
|
||||
**Source:** [`@mariozechner/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui)
|
||||
|
||||
## Component Interface
|
||||
|
||||
All components implement:
|
||||
|
||||
```typescript
|
||||
interface Component {
|
||||
render(width: number): string[];
|
||||
handleInput?(data: string): void;
|
||||
invalidate?(): void;
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
|
||||
| `handleInput?(data)` | Receive keyboard input when component has focus. |
|
||||
| `invalidate?()` | Clear cached render state. |
|
||||
|
||||
## Using Components
|
||||
|
||||
**In hooks** via `ctx.ui.custom()`:
|
||||
|
||||
```typescript
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const handle = ctx.ui.custom(myComponent);
|
||||
// handle.requestRender() - trigger re-render
|
||||
// handle.close() - restore normal UI
|
||||
});
|
||||
```
|
||||
|
||||
**In custom tools** via `pi.ui.custom()`:
|
||||
|
||||
```typescript
|
||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
const handle = pi.ui.custom(myComponent);
|
||||
// ...
|
||||
handle.close();
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Components
|
||||
|
||||
Import from `@mariozechner/pi-tui`:
|
||||
|
||||
```typescript
|
||||
import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui";
|
||||
```
|
||||
|
||||
### Text
|
||||
|
||||
Multi-line text with word wrapping.
|
||||
|
||||
```typescript
|
||||
const text = new Text(
|
||||
"Hello World", // content
|
||||
1, // paddingX (default: 1)
|
||||
1, // paddingY (default: 1)
|
||||
(s) => bgGray(s) // optional background function
|
||||
);
|
||||
text.setText("Updated");
|
||||
```
|
||||
|
||||
### Box
|
||||
|
||||
Container with padding and background color.
|
||||
|
||||
```typescript
|
||||
const box = new Box(
|
||||
1, // paddingX
|
||||
1, // paddingY
|
||||
(s) => bgGray(s) // background function
|
||||
);
|
||||
box.addChild(new Text("Content", 0, 0));
|
||||
box.setBgFn((s) => bgBlue(s));
|
||||
```
|
||||
|
||||
### Container
|
||||
|
||||
Groups child components vertically.
|
||||
|
||||
```typescript
|
||||
const container = new Container();
|
||||
container.addChild(component1);
|
||||
container.addChild(component2);
|
||||
container.removeChild(component1);
|
||||
```
|
||||
|
||||
### Spacer
|
||||
|
||||
Empty vertical space.
|
||||
|
||||
```typescript
|
||||
const spacer = new Spacer(2); // 2 empty lines
|
||||
```
|
||||
|
||||
### Markdown
|
||||
|
||||
Renders markdown with syntax highlighting.
|
||||
|
||||
```typescript
|
||||
const md = new Markdown(
|
||||
"# Title\n\nSome **bold** text",
|
||||
1, // paddingX
|
||||
1, // paddingY
|
||||
theme // MarkdownTheme (see below)
|
||||
);
|
||||
md.setText("Updated markdown");
|
||||
```
|
||||
|
||||
### Image
|
||||
|
||||
Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
|
||||
|
||||
```typescript
|
||||
const image = new Image(
|
||||
base64Data, // base64-encoded image
|
||||
"image/png", // MIME type
|
||||
theme, // ImageTheme
|
||||
{ maxWidthCells: 80, maxHeightCells: 24 }
|
||||
);
|
||||
```
|
||||
|
||||
## Keyboard Input
|
||||
|
||||
Use key detection helpers:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isEnter, isEscape, isTab,
|
||||
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
|
||||
isCtrlC, isCtrlO, isBackspace, isDelete,
|
||||
// ... and more
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
handleInput(data: string) {
|
||||
if (isArrowUp(data)) {
|
||||
this.selectedIndex--;
|
||||
} else if (isEnter(data)) {
|
||||
this.onSelect?.(this.selectedIndex);
|
||||
} else if (isEscape(data)) {
|
||||
this.onCancel?.();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Line Width
|
||||
|
||||
**Critical:** Each line from `render()` must not exceed the `width` parameter.
|
||||
|
||||
```typescript
|
||||
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
render(width: number): string[] {
|
||||
// Truncate long lines
|
||||
return [truncateToWidth(this.text, width)];
|
||||
}
|
||||
```
|
||||
|
||||
Utilities:
|
||||
- `visibleWidth(str)` - Get display width (ignores ANSI codes)
|
||||
- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
|
||||
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
|
||||
|
||||
## Creating Custom Components
|
||||
|
||||
Example: Interactive selector
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isEnter, isEscape, isArrowUp, isArrowDown,
|
||||
truncateToWidth, visibleWidth
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
class MySelector {
|
||||
private items: string[];
|
||||
private selected = 0;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
public onSelect?: (item: string) => void;
|
||||
public onCancel?: () => void;
|
||||
|
||||
constructor(items: string[]) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (isArrowUp(data) && this.selected > 0) {
|
||||
this.selected--;
|
||||
this.invalidate();
|
||||
} else if (isArrowDown(data) && this.selected < this.items.length - 1) {
|
||||
this.selected++;
|
||||
this.invalidate();
|
||||
} else if (isEnter(data)) {
|
||||
this.onSelect?.(this.items[this.selected]);
|
||||
} else if (isEscape(data)) {
|
||||
this.onCancel?.();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
this.cachedLines = this.items.map((item, i) => {
|
||||
const prefix = i === this.selected ? "> " : " ";
|
||||
return truncateToWidth(prefix + item, width);
|
||||
});
|
||||
this.cachedWidth = width;
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage in a hook:
|
||||
|
||||
```typescript
|
||||
pi.registerCommand("pick", {
|
||||
description: "Pick an item",
|
||||
handler: async (args, ctx) => {
|
||||
const items = ["Option A", "Option B", "Option C"];
|
||||
const selector = new MySelector(items);
|
||||
|
||||
let handle: { close: () => void; requestRender: () => void };
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
selector.onSelect = (item) => {
|
||||
ctx.ui.notify(`Selected: ${item}`, "info");
|
||||
handle.close();
|
||||
resolve();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
handle.close();
|
||||
resolve();
|
||||
};
|
||||
handle = ctx.ui.custom(selector);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Theming
|
||||
|
||||
Components accept theme objects for styling.
|
||||
|
||||
**In `renderCall`/`renderResult`**, use the `theme` parameter:
|
||||
|
||||
```typescript
|
||||
renderResult(result, options, theme) {
|
||||
// Use theme.fg() for foreground colors
|
||||
return new Text(theme.fg("success", "Done!"), 0, 0);
|
||||
|
||||
// Use theme.bg() for background colors
|
||||
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
|
||||
}
|
||||
```
|
||||
|
||||
**Foreground colors** (`theme.fg(color, text)`):
|
||||
|
||||
| Category | Colors |
|
||||
|----------|--------|
|
||||
| General | `text`, `accent`, `muted`, `dim` |
|
||||
| Status | `success`, `error`, `warning` |
|
||||
| Borders | `border`, `borderAccent`, `borderMuted` |
|
||||
| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
|
||||
| Tools | `toolTitle`, `toolOutput` |
|
||||
| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
|
||||
| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
|
||||
| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
|
||||
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
|
||||
| Modes | `bashMode` |
|
||||
|
||||
**Background colors** (`theme.bg(color, text)`):
|
||||
|
||||
`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
|
||||
|
||||
**For Markdown**, use `getMarkdownTheme()`:
|
||||
|
||||
```typescript
|
||||
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
||||
import { Markdown } from "@mariozechner/pi-tui";
|
||||
|
||||
renderResult(result, options, theme) {
|
||||
const mdTheme = getMarkdownTheme();
|
||||
return new Markdown(result.details.markdown, 0, 0, mdTheme);
|
||||
}
|
||||
```
|
||||
|
||||
**For custom components**, define your own theme interface:
|
||||
|
||||
```typescript
|
||||
interface MyTheme {
|
||||
selected: (s: string) => string;
|
||||
normal: (s: string) => string;
|
||||
}
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Cache rendered output when possible:
|
||||
|
||||
```typescript
|
||||
class CachedComponent {
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
// ... compute lines ...
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render.
|
||||
|
||||
## Examples
|
||||
|
||||
- **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence
|
||||
- **Custom tool rendering**: [examples/custom-tools/todo/](../examples/custom-tools/todo/) - Custom `renderCall` and `renderResult`
|
||||
|
|
@ -9,10 +9,11 @@ const factory: CustomToolFactory = (_pi) => ({
|
|||
name: Type.String({ description: "Name to greet" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
const { name } = params as { name: string };
|
||||
return {
|
||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
||||
details: { greeted: params.name },
|
||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
||||
details: { greeted: name },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Question Tool - Let the LLM ask the user a question with options
|
||||
*/
|
||||
|
||||
import type { CustomAgentTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
|
|
@ -18,13 +18,13 @@ const QuestionParams = Type.Object({
|
|||
});
|
||||
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
const tool: CustomAgentTool<typeof QuestionParams, QuestionDetails> = {
|
||||
const tool: CustomTool<typeof QuestionParams, QuestionDetails> = {
|
||||
name: "question",
|
||||
label: "Question",
|
||||
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
|
||||
parameters: QuestionParams,
|
||||
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
if (!pi.hasUI) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
|
||||
|
|
@ -41,7 +41,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
|
||||
const answer = await pi.ui.select(params.question, params.options);
|
||||
|
||||
if (answer === null) {
|
||||
if (answer === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled the selection" }],
|
||||
details: { question: params.question, options: params.options, answer: null },
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@ import { spawn } from "node:child_process";
|
|||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentToolResult, Message } from "@mariozechner/pi-ai";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
type CustomToolAPI,
|
||||
type CustomToolFactory,
|
||||
getMarkdownTheme,
|
||||
type ToolAPI,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
|
@ -223,7 +224,7 @@ function writePromptToTempFile(agentName: string, prompt: string): { dir: string
|
|||
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
||||
|
||||
async function runSingleAgent(
|
||||
pi: ToolAPI,
|
||||
pi: CustomToolAPI,
|
||||
agents: AgentConfig[],
|
||||
agentName: string,
|
||||
task: string,
|
||||
|
|
@ -410,7 +411,7 @@ const SubagentParams = Type.Object({
|
|||
});
|
||||
|
||||
const factory: CustomToolFactory = (pi) => {
|
||||
const tool: CustomAgentTool<typeof SubagentParams, SubagentDetails> = {
|
||||
const tool: CustomTool<typeof SubagentParams, SubagentDetails> = {
|
||||
name: "subagent",
|
||||
label: "Subagent",
|
||||
get description() {
|
||||
|
|
@ -432,7 +433,7 @@ const factory: CustomToolFactory = (pi) => {
|
|||
},
|
||||
parameters: SubagentParams,
|
||||
|
||||
async execute(_toolCallId, params, signal, onUpdate) {
|
||||
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
|
||||
const agentScope: AgentScope = params.agentScope ?? "user";
|
||||
const discovery = discoverAgents(pi.cwd, agentScope);
|
||||
const agents = discovery.agents;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@
|
|||
*/
|
||||
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import type { CustomAgentTool, CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
|
||||
import type {
|
||||
CustomTool,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
|
|
@ -43,11 +48,12 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
* Reconstruct state from session entries.
|
||||
* Scans tool results for this tool and applies them in order.
|
||||
*/
|
||||
const reconstructState = (event: ToolSessionEvent) => {
|
||||
const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => {
|
||||
todos = [];
|
||||
nextId = 1;
|
||||
|
||||
for (const entry of event.entries) {
|
||||
// Use getBranch() to get entries on the current branch
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
|
||||
|
|
@ -63,7 +69,7 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
}
|
||||
};
|
||||
|
||||
const tool: CustomAgentTool<typeof TodoParams, TodoDetails> = {
|
||||
const tool: CustomTool<typeof TodoParams, TodoDetails> = {
|
||||
name: "todo",
|
||||
label: "Todo",
|
||||
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
|
||||
|
|
@ -72,7 +78,7 @@ const factory: CustomToolFactory = (_pi) => {
|
|||
// Called on session start/switch/branch/clear
|
||||
onSession: reconstructState,
|
||||
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
switch (params.action) {
|
||||
case "list":
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,97 +2,53 @@
|
|||
|
||||
Example hooks for pi-coding-agent.
|
||||
|
||||
## Examples
|
||||
|
||||
### permission-gate.ts
|
||||
Prompts for confirmation before running dangerous bash commands (rm -rf, sudo, chmod 777, etc.).
|
||||
|
||||
### git-checkpoint.ts
|
||||
Creates git stash checkpoints at each turn, allowing code restoration when branching.
|
||||
|
||||
### protected-paths.ts
|
||||
Blocks writes to protected paths (.env, .git/, node_modules/).
|
||||
|
||||
### file-trigger.ts
|
||||
Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent.
|
||||
|
||||
### confirm-destructive.ts
|
||||
Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events.
|
||||
|
||||
### dirty-repo-guard.ts
|
||||
Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit.
|
||||
|
||||
### auto-commit-on-exit.ts
|
||||
Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message.
|
||||
|
||||
### custom-compaction.ts
|
||||
Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Test directly
|
||||
# Load a hook with --hook flag
|
||||
pi --hook examples/hooks/permission-gate.ts
|
||||
|
||||
# Or copy to hooks directory for persistent use
|
||||
# Or copy to hooks directory for auto-discovery
|
||||
cp permission-gate.ts ~/.pi/agent/hooks/
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
|
||||
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
|
||||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
||||
| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
|
||||
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) |
|
||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
||||
| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
|
||||
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
|
||||
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
|
||||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
||||
|
||||
## Writing Hooks
|
||||
|
||||
See [docs/hooks.md](../../docs/hooks.md) for full documentation.
|
||||
|
||||
### Key Points
|
||||
|
||||
**Hook structure:**
|
||||
```typescript
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" |
|
||||
// "before_branch" | "branch" | "shutdown"
|
||||
// event.targetTurnIndex: number (only for before_branch/branch)
|
||||
// ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI
|
||||
|
||||
// Cancel before_* actions:
|
||||
if (event.reason === "before_clear") {
|
||||
return { cancel: true };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Subscribe to events
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
// Can block tool execution
|
||||
if (dangerous) {
|
||||
return { block: true, reason: "Blocked" };
|
||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
||||
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
|
||||
if (!ok) return { block: true, reason: "Blocked by user" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
// Can modify result
|
||||
return { result: "modified result" };
|
||||
// Register custom commands
|
||||
pi.registerCommand("hello", {
|
||||
description: "Say hello",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.ui.notify("Hello!", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Available events:**
|
||||
- `session` - lifecycle events with before/after variants (can cancel before_* actions)
|
||||
- `agent_start` / `agent_end` - per user prompt
|
||||
- `turn_start` / `turn_end` - per LLM turn
|
||||
- `tool_call` - before tool execution (can block)
|
||||
- `tool_result` - after tool execution (can modify)
|
||||
|
||||
**UI methods:**
|
||||
```typescript
|
||||
const choice = await ctx.ui.select("Title", ["Option A", "Option B"]);
|
||||
const confirmed = await ctx.ui.confirm("Title", "Are you sure?");
|
||||
const input = await ctx.ui.input("Title", "placeholder");
|
||||
ctx.ui.notify("Message", "info"); // or "warning", "error"
|
||||
```
|
||||
|
||||
**Sending messages:**
|
||||
```typescript
|
||||
pi.send("Message to inject into conversation");
|
||||
```
|
||||
|
|
|
|||
|
|
@ -5,14 +5,12 @@
|
|||
* Uses the last assistant message to generate a commit message.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
if (event.reason !== "shutdown") return;
|
||||
|
||||
pi.on("session_shutdown", async (_event, ctx) => {
|
||||
// Check for uncommitted changes
|
||||
const { stdout: status, code } = await ctx.exec("git", ["status", "--porcelain"]);
|
||||
const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]);
|
||||
|
||||
if (code !== 0 || status.trim().length === 0) {
|
||||
// Not a git repo or no changes
|
||||
|
|
@ -20,9 +18,10 @@ export default function (pi: HookAPI) {
|
|||
}
|
||||
|
||||
// Find the last assistant message for commit context
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
let lastAssistantText = "";
|
||||
for (let i = event.entries.length - 1; i >= 0; i--) {
|
||||
const entry = event.entries[i];
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const content = entry.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
|
|
@ -40,8 +39,8 @@ export default function (pi: HookAPI) {
|
|||
const commitMessage = `[pi] ${firstLine.slice(0, 50)}${firstLine.length > 50 ? "..." : ""}`;
|
||||
|
||||
// Stage and commit
|
||||
await ctx.exec("git", ["add", "-A"]);
|
||||
const { code: commitCode } = await ctx.exec("git", ["commit", "-m", commitMessage]);
|
||||
await pi.exec("git", ["add", "-A"]);
|
||||
const { code: commitCode } = await pi.exec("git", ["commit", "-m", commitMessage]);
|
||||
|
||||
if (commitCode === 0 && ctx.hasUI) {
|
||||
ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info");
|
||||
|
|
|
|||
|
|
@ -2,59 +2,56 @@
|
|||
* Confirm Destructive Actions Hook
|
||||
*
|
||||
* Prompts for confirmation before destructive session actions (clear, switch, branch).
|
||||
* Demonstrates how to cancel session events using the before_* variants.
|
||||
* Demonstrates how to cancel session events using the before_* events.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only handle before_* events (the ones that can be cancelled)
|
||||
if (event.reason === "before_new") {
|
||||
if (!ctx.hasUI) return;
|
||||
pi.on("session_before_new", async (_event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const confirmed = await ctx.ui.confirm("Clear session?", "This will delete all messages in the current session.");
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Clear cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_before_switch", async (_event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
// Check if there are unsaved changes (messages since last assistant response)
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const hasUnsavedWork = entries.some(
|
||||
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
|
||||
);
|
||||
|
||||
if (hasUnsavedWork) {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Clear session?",
|
||||
"This will delete all messages in the current session.",
|
||||
"Switch session?",
|
||||
"You have messages in the current session. Switch anyway?",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Clear cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (event.reason === "before_switch") {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
// Check if there are unsaved changes (messages since last assistant response)
|
||||
const hasUnsavedWork = event.entries.some((e) => e.type === "message" && e.message.role === "user");
|
||||
|
||||
if (hasUnsavedWork) {
|
||||
const confirmed = await ctx.ui.confirm(
|
||||
"Switch session?",
|
||||
"You have messages in the current session. Switch anyway?",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
ctx.ui.notify("Switch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.reason === "before_branch") {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const choice = await ctx.ui.select(`Branch from turn ${event.targetTurnIndex}?`, [
|
||||
"Yes, create branch",
|
||||
"No, stay in current session",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, create branch") {
|
||||
ctx.ui.notify("Branch cancelled", "info");
|
||||
ctx.ui.notify("Switch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_before_branch", async (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const choice = await ctx.ui.select(`Branch from entry ${event.entryId.slice(0, 8)}?`, [
|
||||
"Yes, create branch",
|
||||
"No, stay in current session",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, create branch") {
|
||||
ctx.ui.notify("Branch cancelled", "info");
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Replaces the default compaction behavior with a full summary of the entire context.
|
||||
* Instead of keeping the last 20k tokens of conversation turns, this hook:
|
||||
* 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and previousSummary)
|
||||
* 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages)
|
||||
* 2. Discards all old turns completely, keeping only the summary
|
||||
*
|
||||
* This example also demonstrates using a different model (Gemini Flash) for summarization,
|
||||
|
|
@ -14,17 +14,15 @@
|
|||
*/
|
||||
|
||||
import { complete, getModel } from "@mariozechner/pi-ai";
|
||||
import { messageTransformer } from "@mariozechner/pi-coding-agent";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
if (event.reason !== "before_compact") return;
|
||||
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
ctx.ui.notify("Custom compaction hook triggered", "info");
|
||||
|
||||
const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, resolveApiKey, entries, signal } =
|
||||
event;
|
||||
const { preparation, branchEntries: _, signal } = event;
|
||||
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
|
||||
|
||||
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
|
|
@ -34,35 +32,34 @@ export default function (pi: HookAPI) {
|
|||
}
|
||||
|
||||
// Resolve API key for the summarization model
|
||||
const apiKey = await resolveApiKey(model);
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine all messages for full summary
|
||||
const allMessages = [...messagesToSummarize, ...messagesToKeep];
|
||||
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
|
||||
|
||||
ctx.ui.notify(
|
||||
`Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Transform app messages to pi-ai package format
|
||||
const transformedMessages = messageTransformer(allMessages);
|
||||
// Convert messages to readable text format
|
||||
const conversationText = serializeConversation(convertToLlm(allMessages));
|
||||
|
||||
// Include previous summary context if available
|
||||
const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";
|
||||
|
||||
// Build messages that ask for a comprehensive summary
|
||||
const summaryMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}
|
||||
text: `You are a conversation summarizer. Create a comprehensive summary of this conversation that captures:${previousContext}
|
||||
|
||||
1. The main goals and objectives discussed
|
||||
2. Key decisions made and their rationale
|
||||
|
|
@ -73,7 +70,11 @@ export default function (pi: HookAPI) {
|
|||
|
||||
Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.
|
||||
|
||||
Format the summary as structured markdown with clear sections.`,
|
||||
Format the summary as structured markdown with clear sections.
|
||||
|
||||
<conversation>
|
||||
${conversationText}
|
||||
</conversation>`,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
|
|
@ -94,14 +95,12 @@ Format the summary as structured markdown with clear sections.`,
|
|||
return;
|
||||
}
|
||||
|
||||
// Return a compaction entry that discards ALL messages
|
||||
// firstKeptEntryIndex points past all current entries
|
||||
// Return compaction content - SessionManager adds id/parentId
|
||||
// Use firstKeptEntryId from preparation to keep recent messages
|
||||
return {
|
||||
compactionEntry: {
|
||||
type: "compaction" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryIndex: entries.length,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,47 +5,51 @@
|
|||
* Useful to ensure work is committed before switching context.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> {
|
||||
// Check for uncommitted changes
|
||||
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
||||
|
||||
if (code !== 0) {
|
||||
// Not a git repo, allow the action
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanges = stdout.trim().length > 0;
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, block by default
|
||||
return { cancel: true };
|
||||
}
|
||||
|
||||
// Count changed files
|
||||
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
||||
|
||||
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
||||
"Yes, proceed anyway",
|
||||
"No, let me commit first",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, proceed anyway") {
|
||||
ctx.ui.notify("Commit your changes first", "warning");
|
||||
return { cancel: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only guard destructive actions
|
||||
if (event.reason !== "before_new" && event.reason !== "before_switch" && event.reason !== "before_branch") {
|
||||
return;
|
||||
}
|
||||
pi.on("session_before_new", async (_event, ctx) => {
|
||||
return checkDirtyRepo(pi, ctx, "new session");
|
||||
});
|
||||
|
||||
// Check for uncommitted changes
|
||||
const { stdout, code } = await ctx.exec("git", ["status", "--porcelain"]);
|
||||
pi.on("session_before_switch", async (_event, ctx) => {
|
||||
return checkDirtyRepo(pi, ctx, "switch session");
|
||||
});
|
||||
|
||||
if (code !== 0) {
|
||||
// Not a git repo, allow the action
|
||||
return;
|
||||
}
|
||||
|
||||
const hasChanges = stdout.trim().length > 0;
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
// In non-interactive mode, block by default
|
||||
return { cancel: true };
|
||||
}
|
||||
|
||||
// Count changed files
|
||||
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
||||
|
||||
const action =
|
||||
event.reason === "before_new" ? "new session" : event.reason === "before_switch" ? "switch session" : "branch";
|
||||
|
||||
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
||||
"Yes, proceed anyway",
|
||||
"No, let me commit first",
|
||||
]);
|
||||
|
||||
if (choice !== "Yes, proceed anyway") {
|
||||
ctx.ui.notify("Commit your changes first", "warning");
|
||||
return { cancel: true };
|
||||
}
|
||||
pi.on("session_before_branch", async (_event, ctx) => {
|
||||
return checkDirtyRepo(pi, ctx, "branch");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,24 @@
|
|||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.on("session", async (event, ctx) => {
|
||||
if (event.reason !== "start") return;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const triggerFile = "/tmp/agent-trigger.txt";
|
||||
|
||||
fs.watch(triggerFile, () => {
|
||||
try {
|
||||
const content = fs.readFileSync(triggerFile, "utf-8").trim();
|
||||
if (content) {
|
||||
pi.send(`External trigger: ${content}`);
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "file-trigger",
|
||||
content: `External trigger: ${content}`,
|
||||
display: true,
|
||||
},
|
||||
true, // triggerTurn - get LLM to respond
|
||||
);
|
||||
fs.writeFileSync(triggerFile, ""); // Clear after reading
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -5,25 +5,29 @@
|
|||
* When branching, offers to restore code to that point in history.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
const checkpoints = new Map<number, string>();
|
||||
const checkpoints = new Map<string, string>();
|
||||
let currentEntryId: string | undefined;
|
||||
|
||||
pi.on("turn_start", async (event, ctx) => {
|
||||
// Track the current entry ID when user messages are saved
|
||||
pi.on("tool_result", async (_event, ctx) => {
|
||||
const leaf = ctx.sessionManager.getLeafEntry();
|
||||
if (leaf) currentEntryId = leaf.id;
|
||||
});
|
||||
|
||||
pi.on("turn_start", async () => {
|
||||
// Create a git stash entry before LLM makes changes
|
||||
const { stdout } = await ctx.exec("git", ["stash", "create"]);
|
||||
const { stdout } = await pi.exec("git", ["stash", "create"]);
|
||||
const ref = stdout.trim();
|
||||
if (ref) {
|
||||
checkpoints.set(event.turnIndex, ref);
|
||||
if (ref && currentEntryId) {
|
||||
checkpoints.set(currentEntryId, ref);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session", async (event, ctx) => {
|
||||
// Only handle before_branch events
|
||||
if (event.reason !== "before_branch") return;
|
||||
|
||||
const ref = checkpoints.get(event.targetTurnIndex);
|
||||
pi.on("session_before_branch", async (event, ctx) => {
|
||||
const ref = checkpoints.get(event.entryId);
|
||||
if (!ref) return;
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
|
|
@ -37,7 +41,7 @@ export default function (pi: HookAPI) {
|
|||
]);
|
||||
|
||||
if (choice?.startsWith("Yes")) {
|
||||
await ctx.exec("git", ["stash", "apply", ref]);
|
||||
await pi.exec("git", ["stash", "apply", ref]);
|
||||
ctx.ui.notify("Code restored to checkpoint", "info");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Patterns checked: rm -rf, sudo, chmod/chown 777
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Useful for preventing accidental modifications to sensitive files.
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
const protectedPaths = [".env", ".git/", "node_modules/"];
|
||||
|
|
|
|||
119
packages/coding-agent/examples/hooks/qna.ts
Normal file
119
packages/coding-agent/examples/hooks/qna.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Q&A extraction hook - extracts questions from assistant responses
|
||||
*
|
||||
* Demonstrates the "prompt generator" pattern:
|
||||
* 1. /qna command gets the last assistant message
|
||||
* 2. Shows a spinner while extracting (hides editor)
|
||||
* 3. Loads the result into the editor for user to fill in answers
|
||||
*/
|
||||
|
||||
import { complete, type UserMessage } from "@mariozechner/pi-ai";
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.
|
||||
|
||||
Output format:
|
||||
- List each question on its own line, prefixed with "Q: "
|
||||
- After each question, add a blank line for the answer prefixed with "A: "
|
||||
- If no questions are found, output "No questions found in the last message."
|
||||
|
||||
Example output:
|
||||
Q: What is your preferred database?
|
||||
A:
|
||||
|
||||
Q: Should we use TypeScript or JavaScript?
|
||||
A:
|
||||
|
||||
Keep questions in the order they appeared. Be concise.`;
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.registerCommand("qna", {
|
||||
description: "Extract questions from last assistant message into editor",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("qna requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.model) {
|
||||
ctx.ui.notify("No model selected", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last assistant message on the current branch
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
let lastAssistantText: string | undefined;
|
||||
|
||||
for (let i = branch.length - 1; i >= 0; i--) {
|
||||
const entry = branch[i];
|
||||
if (entry.type === "message") {
|
||||
const msg = entry.message;
|
||||
if ("role" in msg && msg.role === "assistant") {
|
||||
if (msg.stopReason !== "stop") {
|
||||
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
|
||||
return;
|
||||
}
|
||||
const textParts = msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text);
|
||||
if (textParts.length > 0) {
|
||||
lastAssistantText = textParts.join("\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistantText) {
|
||||
ctx.ui.notify("No assistant messages found", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run extraction with loader UI
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
|
||||
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
// Do the work
|
||||
const doExtract = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
||||
const userMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: lastAssistantText! }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const response = await complete(
|
||||
ctx.model!,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
doExtract()
|
||||
.then(done)
|
||||
.catch(() => done(null));
|
||||
|
||||
return loader;
|
||||
});
|
||||
|
||||
if (result === null) {
|
||||
ctx.ui.notify("Cancelled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.setEditorText(result);
|
||||
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
343
packages/coding-agent/examples/hooks/snake.ts
Normal file
343
packages/coding-agent/examples/hooks/snake.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* Snake game hook - play snake with /snake command
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
const GAME_WIDTH = 40;
|
||||
const GAME_HEIGHT = 15;
|
||||
const TICK_MS = 100;
|
||||
|
||||
type Direction = "up" | "down" | "left" | "right";
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
interface GameState {
|
||||
snake: Point[];
|
||||
food: Point;
|
||||
direction: Direction;
|
||||
nextDirection: Direction;
|
||||
score: number;
|
||||
gameOver: boolean;
|
||||
highScore: number;
|
||||
}
|
||||
|
||||
function createInitialState(): GameState {
|
||||
const startX = Math.floor(GAME_WIDTH / 2);
|
||||
const startY = Math.floor(GAME_HEIGHT / 2);
|
||||
return {
|
||||
snake: [
|
||||
{ x: startX, y: startY },
|
||||
{ x: startX - 1, y: startY },
|
||||
{ x: startX - 2, y: startY },
|
||||
],
|
||||
food: spawnFood([{ x: startX, y: startY }]),
|
||||
direction: "right",
|
||||
nextDirection: "right",
|
||||
score: 0,
|
||||
gameOver: false,
|
||||
highScore: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function spawnFood(snake: Point[]): Point {
|
||||
let food: Point;
|
||||
do {
|
||||
food = {
|
||||
x: Math.floor(Math.random() * GAME_WIDTH),
|
||||
y: Math.floor(Math.random() * GAME_HEIGHT),
|
||||
};
|
||||
} while (snake.some((s) => s.x === food.x && s.y === food.y));
|
||||
return food;
|
||||
}
|
||||
|
||||
class SnakeComponent {
|
||||
private state: GameState;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private onClose: () => void;
|
||||
private onSave: (state: GameState | null) => void;
|
||||
private tui: { requestRender: () => void };
|
||||
private cachedLines: string[] = [];
|
||||
private cachedWidth = 0;
|
||||
private version = 0;
|
||||
private cachedVersion = -1;
|
||||
private paused: boolean;
|
||||
|
||||
constructor(
|
||||
tui: { requestRender: () => void },
|
||||
onClose: () => void,
|
||||
onSave: (state: GameState | null) => void,
|
||||
savedState?: GameState,
|
||||
) {
|
||||
this.tui = tui;
|
||||
if (savedState && !savedState.gameOver) {
|
||||
// Resume from saved state, start paused
|
||||
this.state = savedState;
|
||||
this.paused = true;
|
||||
} else {
|
||||
// New game or saved game was over
|
||||
this.state = createInitialState();
|
||||
if (savedState) {
|
||||
this.state.highScore = savedState.highScore;
|
||||
}
|
||||
this.paused = false;
|
||||
this.startGame();
|
||||
}
|
||||
this.onClose = onClose;
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
private startGame(): void {
|
||||
this.interval = setInterval(() => {
|
||||
if (!this.state.gameOver) {
|
||||
this.tick();
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
// Apply queued direction change
|
||||
this.state.direction = this.state.nextDirection;
|
||||
|
||||
// Calculate new head position
|
||||
const head = this.state.snake[0];
|
||||
let newHead: Point;
|
||||
|
||||
switch (this.state.direction) {
|
||||
case "up":
|
||||
newHead = { x: head.x, y: head.y - 1 };
|
||||
break;
|
||||
case "down":
|
||||
newHead = { x: head.x, y: head.y + 1 };
|
||||
break;
|
||||
case "left":
|
||||
newHead = { x: head.x - 1, y: head.y };
|
||||
break;
|
||||
case "right":
|
||||
newHead = { x: head.x + 1, y: head.y };
|
||||
break;
|
||||
}
|
||||
|
||||
// Check wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check self collision
|
||||
if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move snake
|
||||
this.state.snake.unshift(newHead);
|
||||
|
||||
// Check food collision
|
||||
if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
|
||||
this.state.score += 10;
|
||||
if (this.state.score > this.state.highScore) {
|
||||
this.state.highScore = this.state.score;
|
||||
}
|
||||
this.state.food = spawnFood(this.state.snake);
|
||||
} else {
|
||||
this.state.snake.pop();
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// If paused (resuming), wait for any key
|
||||
if (this.paused) {
|
||||
if (isEscape(data) || data === "q" || data === "Q") {
|
||||
// Quit without clearing save
|
||||
this.dispose();
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
// Any other key resumes
|
||||
this.paused = false;
|
||||
this.startGame();
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC to pause and save
|
||||
if (isEscape(data)) {
|
||||
this.dispose();
|
||||
this.onSave(this.state);
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Q to quit without saving (clears saved state)
|
||||
if (data === "q" || data === "Q") {
|
||||
this.dispose();
|
||||
this.onSave(null); // Clear saved state
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys or WASD
|
||||
if (isArrowUp(data) || data === "w" || data === "W") {
|
||||
if (this.state.direction !== "down") this.state.nextDirection = "up";
|
||||
} else if (isArrowDown(data) || data === "s" || data === "S") {
|
||||
if (this.state.direction !== "up") this.state.nextDirection = "down";
|
||||
} else if (isArrowRight(data) || data === "d" || data === "D") {
|
||||
if (this.state.direction !== "left") this.state.nextDirection = "right";
|
||||
} else if (isArrowLeft(data) || data === "a" || data === "A") {
|
||||
if (this.state.direction !== "right") this.state.nextDirection = "left";
|
||||
}
|
||||
|
||||
// Restart on game over
|
||||
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
|
||||
const highScore = this.state.highScore;
|
||||
this.state = createInitialState();
|
||||
this.state.highScore = highScore;
|
||||
this.onSave(null); // Clear saved state on restart
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = 0;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect)
|
||||
const cellWidth = 2;
|
||||
const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
|
||||
const effectiveHeight = GAME_HEIGHT;
|
||||
|
||||
// Colors
|
||||
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
||||
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
||||
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
|
||||
const boxWidth = effectiveWidth * cellWidth;
|
||||
|
||||
// Helper to pad content inside box
|
||||
const boxLine = (content: string) => {
|
||||
const contentLen = visibleWidth(content);
|
||||
const padding = Math.max(0, boxWidth - contentLen);
|
||||
return dim(" │") + content + " ".repeat(padding) + dim("│");
|
||||
};
|
||||
|
||||
// Top border
|
||||
lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width));
|
||||
|
||||
// Header with score
|
||||
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
||||
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
|
||||
const title = `${bold(green("SNAKE"))} │ ${scoreText} │ ${highText}`;
|
||||
lines.push(this.padLine(boxLine(title), width));
|
||||
|
||||
// Separator
|
||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||
|
||||
// Game grid
|
||||
for (let y = 0; y < effectiveHeight; y++) {
|
||||
let row = "";
|
||||
for (let x = 0; x < effectiveWidth; x++) {
|
||||
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
|
||||
const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
|
||||
const isFood = this.state.food.x === x && this.state.food.y === y;
|
||||
|
||||
if (isHead) {
|
||||
row += green("██"); // Snake head (2 chars)
|
||||
} else if (isBody) {
|
||||
row += green("▓▓"); // Snake body (2 chars)
|
||||
} else if (isFood) {
|
||||
row += red("◆ "); // Food (2 chars)
|
||||
} else {
|
||||
row += " "; // Empty cell (2 spaces)
|
||||
}
|
||||
}
|
||||
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
|
||||
}
|
||||
|
||||
// Separator
|
||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
||||
|
||||
// Footer
|
||||
let footer: string;
|
||||
if (this.paused) {
|
||||
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
|
||||
} else if (this.state.gameOver) {
|
||||
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
|
||||
} else {
|
||||
footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`;
|
||||
}
|
||||
lines.push(this.padLine(boxLine(footer), width));
|
||||
|
||||
// Bottom border
|
||||
lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
|
||||
|
||||
this.cachedLines = lines;
|
||||
this.cachedWidth = width;
|
||||
this.cachedVersion = this.version;
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private padLine(line: string, width: number): string {
|
||||
// Calculate visible length (strip ANSI codes)
|
||||
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
const padding = Math.max(0, width - visibleLen);
|
||||
return line + " ".repeat(padding);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SNAKE_SAVE_TYPE = "snake-save";
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.registerCommand("snake", {
|
||||
description: "Play Snake!",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Snake requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load saved state from session
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
let savedState: GameState | undefined;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
|
||||
savedState = entry.data as GameState;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.ui.custom((tui, _theme, done) => {
|
||||
return new SnakeComponent(
|
||||
tui,
|
||||
() => done(undefined),
|
||||
(state) => {
|
||||
// Save or clear state
|
||||
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
||||
},
|
||||
savedState,
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
|
||||
*/
|
||||
|
||||
import { createAgentSession } from "../../src/index.js";
|
||||
import { createAgentSession } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const { session } = await createAgentSession();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { createAgentSession, discoverAuthStorage, discoverModels } from "../../src/index.js";
|
||||
import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Set up auth storage and model registry
|
||||
const authStorage = discoverAuthStorage();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Shows how to replace or modify the default system prompt.
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager } from "../../src/index.js";
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Option 1: Replace prompt entirely
|
||||
const { session: session1 } = await createAgentSession({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Discover, filter, merge, or replace them.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "../../src/index.js";
|
||||
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
|
||||
const allSkills = discoverSkills();
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@
|
|||
* tools resolve paths relative to your cwd, not process.cwd().
|
||||
*/
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
bashTool, // read, bash, edit, write - uses process.cwd()
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
createAgentSession,
|
||||
createBashTool,
|
||||
createCodingTools, // Factory: creates tools for specific cwd
|
||||
|
|
@ -21,7 +20,8 @@ import {
|
|||
readOnlyTools, // read, grep, find, ls - uses process.cwd()
|
||||
readTool,
|
||||
SessionManager,
|
||||
} from "../../src/index.js";
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
// Read-only mode (no edit/write) - uses process.cwd()
|
||||
await createAgentSession({
|
||||
|
|
@ -55,7 +55,7 @@ await createAgentSession({
|
|||
console.log("Specific tools with custom cwd session created");
|
||||
|
||||
// Inline custom tool (needs TypeBox schema)
|
||||
const weatherTool: CustomAgentTool = {
|
||||
const weatherTool: CustomTool = {
|
||||
name: "get_weather",
|
||||
label: "Get Weather",
|
||||
description: "Get current weather for a city",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Hooks intercept agent events for logging, blocking, or modification.
|
||||
*/
|
||||
|
||||
import { createAgentSession, type HookFactory, SessionManager } from "../../src/index.js";
|
||||
import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Logging hook
|
||||
const loggingHook: HookFactory = (api) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Context files provide project-specific instructions loaded into the system prompt.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverContextFiles, SessionManager } from "../../src/index.js";
|
||||
import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover AGENTS.md files walking up from cwd
|
||||
const discovered = discoverContextFiles();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@
|
|||
* File-based commands that inject content when invoked with /commandname.
|
||||
*/
|
||||
|
||||
import { createAgentSession, discoverSlashCommands, type FileSlashCommand, SessionManager } from "../../src/index.js";
|
||||
import {
|
||||
createAgentSession,
|
||||
discoverSlashCommands,
|
||||
type FileSlashCommand,
|
||||
SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
|
||||
const discovered = discoverSlashCommands();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
discoverModels,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
} from "../../src/index.js";
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
|
||||
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Override settings using SettingsManager.
|
||||
*/
|
||||
|
||||
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js";
|
||||
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Load current settings (merged global + project)
|
||||
const settings = loadSettings();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Control session persistence: in-memory, new file, continue, or open specific.
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager } from "../../src/index.js";
|
||||
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// In-memory (no persistence)
|
||||
const { session: inMemory } = await createAgentSession({
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@
|
|||
*/
|
||||
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
AuthStorage,
|
||||
type CustomAgentTool,
|
||||
type CustomTool,
|
||||
createAgentSession,
|
||||
createBashTool,
|
||||
createReadTool,
|
||||
|
|
@ -20,7 +19,8 @@ import {
|
|||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from "../../src/index.js";
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
// Custom auth storage location
|
||||
const authStorage = new AuthStorage("/tmp/my-agent/auth.json");
|
||||
|
|
@ -42,7 +42,7 @@ const auditHook: HookFactory = (api) => {
|
|||
};
|
||||
|
||||
// Inline custom tool
|
||||
const statusTool: CustomAgentTool = {
|
||||
const statusTool: CustomTool = {
|
||||
name: "status",
|
||||
label: "Status",
|
||||
description: "Get system status",
|
||||
|
|
@ -68,15 +68,12 @@ const cwd = process.cwd();
|
|||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir: "/tmp/my-agent",
|
||||
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
|
||||
systemPrompt: `You are a minimal assistant.
|
||||
Available: read, bash, status. Be concise.`,
|
||||
|
||||
// Use factory functions with the same cwd to ensure path resolution works correctly
|
||||
tools: [createReadTool(cwd), createBashTool(cwd)],
|
||||
customTools: [{ tool: statusTool }],
|
||||
|
|
|
|||
|
|
@ -3,21 +3,21 @@
|
|||
*/
|
||||
|
||||
import { access, readFile, stat } from "node:fs/promises";
|
||||
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { resolve } from "path";
|
||||
import { resolveReadPath } from "../core/tools/path-utils.js";
|
||||
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
|
||||
|
||||
export interface ProcessedFiles {
|
||||
textContent: string;
|
||||
imageAttachments: Attachment[];
|
||||
text: string;
|
||||
images: ImageContent[];
|
||||
}
|
||||
|
||||
/** Process @file arguments into text content and image attachments */
|
||||
export async function processFileArguments(fileArgs: string[]): Promise<ProcessedFiles> {
|
||||
let textContent = "";
|
||||
const imageAttachments: Attachment[] = [];
|
||||
let text = "";
|
||||
const images: ImageContent[] = [];
|
||||
|
||||
for (const fileArg of fileArgs) {
|
||||
// Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces)
|
||||
|
|
@ -45,24 +45,21 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
|
|||
const content = await readFile(absolutePath);
|
||||
const base64Content = content.toString("base64");
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
const attachment: ImageContent = {
|
||||
type: "image",
|
||||
fileName: absolutePath.split("/").pop() || absolutePath,
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
content: base64Content,
|
||||
data: base64Content,
|
||||
};
|
||||
|
||||
imageAttachments.push(attachment);
|
||||
images.push(attachment);
|
||||
|
||||
// Add text reference to image
|
||||
textContent += `<file name="${absolutePath}"></file>\n`;
|
||||
text += `<file name="${absolutePath}"></file>\n`;
|
||||
} else {
|
||||
// Handle text file
|
||||
try {
|
||||
const content = await readFile(absolutePath, "utf-8");
|
||||
textContent += `<file name="${absolutePath}">\n${content}\n</file>\n`;
|
||||
text += `<file name="${absolutePath}">\n${content}\n</file>\n`;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`));
|
||||
|
|
@ -71,5 +68,5 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
|
|||
}
|
||||
}
|
||||
|
||||
return { textContent, imageAttachments };
|
||||
return { text, images };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ export function getDocsPath(): string {
|
|||
return resolve(join(getPackageDir(), "docs"));
|
||||
}
|
||||
|
||||
/** Get path to examples directory */
|
||||
export function getExamplesPath(): string {
|
||||
return resolve(join(getPackageDir(), "examples"));
|
||||
}
|
||||
|
||||
/** Get path to CHANGELOG.md */
|
||||
export function getChangelogPath(): string {
|
||||
return resolve(join(getPackageDir(), "CHANGELOG.md"));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -94,8 +94,8 @@ export class AuthStorage {
|
|||
/**
|
||||
* Get credential for a provider.
|
||||
*/
|
||||
get(provider: string): AuthCredential | null {
|
||||
return this.data[provider] ?? null;
|
||||
get(provider: string): AuthCredential | undefined {
|
||||
return this.data[provider] ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -191,7 +191,7 @@ export class AuthStorage {
|
|||
* 4. Environment variable
|
||||
* 5. Fallback resolver (models.json custom providers)
|
||||
*/
|
||||
async getApiKey(provider: string): Promise<string | null> {
|
||||
async getApiKey(provider: string): Promise<string | undefined> {
|
||||
// Runtime override takes highest priority
|
||||
const runtimeKey = this.runtimeOverrides.get(provider);
|
||||
if (runtimeKey) {
|
||||
|
|
@ -230,6 +230,6 @@ export class AuthStorage {
|
|||
if (envKey) return envKey;
|
||||
|
||||
// Fall back to custom resolver (e.g., models.json custom providers)
|
||||
return this.fallbackResolver?.(provider) ?? null;
|
||||
return this.fallbackResolver?.(provider) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ export interface BashExecutorOptions {
|
|||
export interface BashResult {
|
||||
/** Combined stdout + stderr output (sanitized, possibly truncated) */
|
||||
output: string;
|
||||
/** Process exit code (null if killed/cancelled) */
|
||||
exitCode: number | null;
|
||||
/** Process exit code (undefined if killed/cancelled) */
|
||||
exitCode: number | undefined;
|
||||
/** Whether the command was cancelled via signal */
|
||||
cancelled: boolean;
|
||||
/** Whether the output was truncated */
|
||||
|
|
@ -88,7 +88,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
|
|||
child.kill();
|
||||
resolve({
|
||||
output: "",
|
||||
exitCode: null,
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
truncated: false,
|
||||
});
|
||||
|
|
@ -154,7 +154,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
|
|||
|
||||
resolve({
|
||||
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
||||
exitCode: code,
|
||||
exitCode: cancelled ? undefined : code,
|
||||
cancelled,
|
||||
truncated: truncationResult.truncated,
|
||||
fullOutputPath: tempFilePath,
|
||||
|
|
|
|||
|
|
@ -1,530 +0,0 @@
|
|||
/**
|
||||
* Context compaction for long sessions.
|
||||
*
|
||||
* Pure functions for compaction logic. The session manager handles I/O,
|
||||
* and after compaction the session is reloaded.
|
||||
*/
|
||||
|
||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
||||
import { complete } from "@mariozechner/pi-ai";
|
||||
import { messageTransformer } from "./messages.js";
|
||||
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionSettings {
|
||||
enabled: boolean;
|
||||
reserveTokens: number;
|
||||
keepRecentTokens: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 16384,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Token calculation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate total context tokens from usage.
|
||||
* Uses the native totalTokens field when available, falls back to computing from components.
|
||||
*/
|
||||
export function calculateContextTokens(usage: Usage): number {
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage from an assistant message if available.
|
||||
* Skips aborted and error messages as they don't have valid usage data.
|
||||
*/
|
||||
function getAssistantUsage(msg: AppMessage): Usage | null {
|
||||
if (msg.role === "assistant" && "usage" in msg) {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
|
||||
return assistantMsg.usage;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last non-aborted assistant message usage from session entries.
|
||||
*/
|
||||
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const usage = getAssistantUsage(entry.message);
|
||||
if (usage) return usage;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compaction should trigger based on context usage.
|
||||
*/
|
||||
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
|
||||
if (!settings.enabled) return false;
|
||||
return contextTokens > contextWindow - settings.reserveTokens;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cut point detection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using chars/4 heuristic.
|
||||
* This is conservative (overestimates tokens).
|
||||
*/
|
||||
export function estimateTokens(message: AppMessage): number {
|
||||
let chars = 0;
|
||||
|
||||
// Handle bashExecution messages
|
||||
if (message.role === "bashExecution") {
|
||||
const bash = message as unknown as { command: string; output: string };
|
||||
chars = bash.command.length + bash.output.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Handle user messages
|
||||
if (message.role === "user") {
|
||||
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (typeof content === "string") {
|
||||
chars = content.length;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
if (message.role === "assistant") {
|
||||
const assistant = message as AssistantMessage;
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "text") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
} else if (block.type === "toolCall") {
|
||||
chars += block.name.length + JSON.stringify(block.arguments).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Handle tool results
|
||||
if (message.role === "toolResult") {
|
||||
const toolResult = message as { content: Array<{ type: string; text?: string }> };
|
||||
for (const block of toolResult.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid cut points: indices of user, assistant, or bashExecution messages.
|
||||
* Never cut at tool results (they must follow their tool call).
|
||||
* When we cut at an assistant message with tool calls, its tool results follow it
|
||||
* and will be kept.
|
||||
* BashExecutionMessage is treated like a user message (user-initiated context).
|
||||
*/
|
||||
function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
|
||||
const cutPoints: number[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const role = entry.message.role;
|
||||
// user, assistant, and bashExecution are valid cut points
|
||||
// toolResult must stay with its preceding tool call
|
||||
if (role === "user" || role === "assistant" || role === "bashExecution") {
|
||||
cutPoints.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cutPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
|
||||
* Returns -1 if no turn start found before the index.
|
||||
* BashExecutionMessage is treated like a user message for turn boundaries.
|
||||
*/
|
||||
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
|
||||
for (let i = entryIndex; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const role = entry.message.role;
|
||||
if (role === "user" || role === "bashExecution") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface CutPointResult {
|
||||
/** Index of first entry to keep */
|
||||
firstKeptEntryIndex: number;
|
||||
/** Index of user message that starts the turn being split, or -1 if not splitting */
|
||||
turnStartIndex: number;
|
||||
/** Whether this cut splits a turn (cut point is not a user message) */
|
||||
isSplitTurn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
|
||||
*
|
||||
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
|
||||
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
|
||||
*
|
||||
* Can cut at user OR assistant messages (never tool results). When cutting at an
|
||||
* assistant message with tool calls, its tool results come after and will be kept.
|
||||
*
|
||||
* Returns CutPointResult with:
|
||||
* - firstKeptEntryIndex: the entry index to start keeping from
|
||||
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
|
||||
* - isSplitTurn: whether we're cutting in the middle of a turn
|
||||
*
|
||||
* Only considers entries between `startIndex` and `endIndex` (exclusive).
|
||||
*/
|
||||
export function findCutPoint(
|
||||
entries: SessionEntry[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
keepRecentTokens: number,
|
||||
): CutPointResult {
|
||||
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
|
||||
|
||||
if (cutPoints.length === 0) {
|
||||
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
||||
}
|
||||
|
||||
// Walk backwards from newest, accumulating estimated message sizes
|
||||
let accumulatedTokens = 0;
|
||||
let cutIndex = startIndex; // Default: keep everything in range
|
||||
|
||||
for (let i = endIndex - 1; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
// Estimate this message's size
|
||||
const messageTokens = estimateTokens(entry.message);
|
||||
accumulatedTokens += messageTokens;
|
||||
|
||||
// Check if we've exceeded the budget
|
||||
if (accumulatedTokens >= keepRecentTokens) {
|
||||
// Find the closest valid cut point at or after this entry
|
||||
for (let c = 0; c < cutPoints.length; c++) {
|
||||
if (cutPoints[c] >= i) {
|
||||
cutIndex = cutPoints[c];
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
|
||||
while (cutIndex > startIndex) {
|
||||
const prevEntry = entries[cutIndex - 1];
|
||||
// Stop at compaction boundaries
|
||||
if (prevEntry.type === "compaction") {
|
||||
break;
|
||||
}
|
||||
if (prevEntry.type === "message") {
|
||||
// Stop if we hit any message
|
||||
break;
|
||||
}
|
||||
// Include this non-message entry (bash, settings change, etc.)
|
||||
cutIndex--;
|
||||
}
|
||||
|
||||
// Determine if this is a split turn
|
||||
const cutEntry = entries[cutIndex];
|
||||
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
||||
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
|
||||
return {
|
||||
firstKeptEntryIndex: cutIndex,
|
||||
turnStartIndex,
|
||||
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization
|
||||
// ============================================================================
|
||||
|
||||
const SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
|
||||
|
||||
Include:
|
||||
- Current progress and key decisions made
|
||||
- Important context, constraints, or user preferences
|
||||
- Absolute file paths of any relevant files that were read or modified
|
||||
- What remains to be done (clear next steps)
|
||||
- Any critical data, examples, or references needed to continue
|
||||
|
||||
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
|
||||
|
||||
/**
|
||||
* Generate a summary of the conversation using the LLM.
|
||||
*/
|
||||
export async function generateSummary(
|
||||
currentMessages: AppMessage[],
|
||||
model: Model<any>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.8 * reserveTokens);
|
||||
|
||||
const prompt = customInstructions
|
||||
? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
|
||||
: SUMMARIZATION_PROMPT;
|
||||
|
||||
// Transform custom messages (like bashExecution) to LLM-compatible messages
|
||||
const transformedMessages = messageTransformer(currentMessages);
|
||||
|
||||
const summarizationMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: prompt }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
|
||||
|
||||
const textContent = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
return textContent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Compaction Preparation (for hooks)
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionPreparation {
|
||||
cutPoint: CutPointResult;
|
||||
/** Messages that will be summarized and discarded */
|
||||
messagesToSummarize: AppMessage[];
|
||||
/** Messages that will be kept after the summary (recent turns) */
|
||||
messagesToKeep: AppMessage[];
|
||||
tokensBefore: number;
|
||||
boundaryStart: number;
|
||||
}
|
||||
|
||||
export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null {
|
||||
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].type === "compaction") {
|
||||
prevCompactionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const boundaryStart = prevCompactionIndex + 1;
|
||||
const boundaryEnd = entries.length;
|
||||
|
||||
const lastUsage = getLastAssistantUsage(entries);
|
||||
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
||||
|
||||
const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||
|
||||
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
||||
|
||||
// Messages to summarize (will be discarded after summary)
|
||||
const messagesToSummarize: AppMessage[] = [];
|
||||
for (let i = boundaryStart; i < historyEnd; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
messagesToSummarize.push(entry.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Messages to keep (recent turns, kept after summary)
|
||||
const messagesToKeep: AppMessage[] = [];
|
||||
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
messagesToKeep.push(entry.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { cutPoint, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main compaction function
|
||||
// ============================================================================
|
||||
|
||||
const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn.
|
||||
This is the PREFIX of a turn that was too large to keep in full. The SUFFIX (recent work) is being kept.
|
||||
|
||||
Create a handoff summary that captures:
|
||||
- What the user originally asked for in this turn
|
||||
- Key decisions and progress made early in this turn
|
||||
- Important context needed to understand the kept suffix
|
||||
|
||||
Be concise. Focus on information needed to understand the retained recent work.`;
|
||||
|
||||
/**
|
||||
* Calculate compaction and generate summary.
|
||||
* Returns the CompactionEntry to append to the session file.
|
||||
*
|
||||
* @param entries - All session entries
|
||||
* @param model - Model to use for summarization
|
||||
* @param settings - Compaction settings
|
||||
* @param apiKey - API key for LLM
|
||||
* @param signal - Optional abort signal
|
||||
* @param customInstructions - Optional custom focus for the summary
|
||||
*/
|
||||
export async function compact(
|
||||
entries: SessionEntry[],
|
||||
model: Model<any>,
|
||||
settings: CompactionSettings,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
): Promise<CompactionEntry> {
|
||||
// Don't compact if the last entry is already a compaction
|
||||
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
|
||||
throw new Error("Already compacted");
|
||||
}
|
||||
|
||||
// Find previous compaction boundary
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i].type === "compaction") {
|
||||
prevCompactionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const boundaryStart = prevCompactionIndex + 1;
|
||||
const boundaryEnd = entries.length;
|
||||
|
||||
// Get token count before compaction
|
||||
const lastUsage = getLastAssistantUsage(entries);
|
||||
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
||||
|
||||
// Find cut point (entry index) within the valid range
|
||||
const cutResult = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||
|
||||
// Extract messages for history summary (before the turn that contains the cut point)
|
||||
const historyEnd = cutResult.isSplitTurn ? cutResult.turnStartIndex : cutResult.firstKeptEntryIndex;
|
||||
const historyMessages: AppMessage[] = [];
|
||||
for (let i = boundaryStart; i < historyEnd; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
historyMessages.push(entry.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Include previous summary if there was a compaction
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||
historyMessages.unshift({
|
||||
role: "user",
|
||||
content: `Previous session summary:\n${prevCompaction.summary}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract messages for turn prefix summary (if splitting a turn)
|
||||
const turnPrefixMessages: AppMessage[] = [];
|
||||
if (cutResult.isSplitTurn) {
|
||||
for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
turnPrefixMessages.push(entry.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summaries (can be parallel if both needed) and merge into one
|
||||
let summary: string;
|
||||
|
||||
if (cutResult.isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
// Generate both summaries in parallel
|
||||
const [historyResult, turnPrefixResult] = await Promise.all([
|
||||
historyMessages.length > 0
|
||||
? generateSummary(historyMessages, model, settings.reserveTokens, apiKey, signal, customInstructions)
|
||||
: Promise.resolve("No prior history."),
|
||||
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
|
||||
]);
|
||||
// Merge into single summary
|
||||
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
|
||||
} else {
|
||||
// Just generate history summary
|
||||
summary = await generateSummary(
|
||||
historyMessages,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
signal,
|
||||
customInstructions,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "compaction",
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryIndex: cutResult.firstKeptEntryIndex,
|
||||
tokensBefore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary for a turn prefix (when splitting a turn).
|
||||
*/
|
||||
async function generateTurnPrefixSummary(
|
||||
messages: AppMessage[],
|
||||
model: Model<any>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
||||
|
||||
const transformedMessages = messageTransformer(messages);
|
||||
const summarizationMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* Branch summarization for tree navigation.
|
||||
*
|
||||
* When navigating to a different point in the session tree, this generates
|
||||
* a summary of the branch being left so context isn't lost.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { completeSimple } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createHookMessage,
|
||||
} from "../messages.js";
|
||||
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
|
||||
import { estimateTokens } from "./compaction.js";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
SUMMARIZATION_SYSTEM_PROMPT,
|
||||
serializeConversation,
|
||||
} from "./utils.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface BranchSummaryResult {
|
||||
summary?: string;
|
||||
readFiles?: string[];
|
||||
modifiedFiles?: string[];
|
||||
aborted?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Details stored in BranchSummaryEntry.details for file tracking */
|
||||
export interface BranchSummaryDetails {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
export type { FileOperations } from "./utils.js";
|
||||
|
||||
export interface BranchPreparation {
|
||||
/** Messages extracted for summarization, in chronological order */
|
||||
messages: AgentMessage[];
|
||||
/** File operations extracted from tool calls */
|
||||
fileOps: FileOperations;
|
||||
/** Total estimated tokens in messages */
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface CollectEntriesResult {
|
||||
/** Entries to summarize, in chronological order */
|
||||
entries: SessionEntry[];
|
||||
/** Common ancestor between old and new position, if any */
|
||||
commonAncestorId: string | null;
|
||||
}
|
||||
|
||||
export interface GenerateBranchSummaryOptions {
|
||||
/** Model to use for summarization */
|
||||
model: Model<any>;
|
||||
/** API key for the model */
|
||||
apiKey: string;
|
||||
/** Abort signal for cancellation */
|
||||
signal: AbortSignal;
|
||||
/** Optional custom instructions for summarization */
|
||||
customInstructions?: string;
|
||||
/** Tokens reserved for prompt + LLM response (default 16384) */
|
||||
reserveTokens?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entry Collection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Collect entries that should be summarized when navigating from one position to another.
|
||||
*
|
||||
* Walks from oldLeafId back to the common ancestor with targetId, collecting entries
|
||||
* along the way. Does NOT stop at compaction boundaries - those are included and their
|
||||
* summaries become context.
|
||||
*
|
||||
* @param session - Session manager (read-only access)
|
||||
* @param oldLeafId - Current position (where we're navigating from)
|
||||
* @param targetId - Target position (where we're navigating to)
|
||||
* @returns Entries to summarize and the common ancestor
|
||||
*/
|
||||
export function collectEntriesForBranchSummary(
|
||||
session: ReadonlySessionManager,
|
||||
oldLeafId: string | null,
|
||||
targetId: string,
|
||||
): CollectEntriesResult {
|
||||
// If no old position, nothing to summarize
|
||||
if (!oldLeafId) {
|
||||
return { entries: [], commonAncestorId: null };
|
||||
}
|
||||
|
||||
// Find common ancestor (deepest node that's on both paths)
|
||||
const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));
|
||||
const targetPath = session.getBranch(targetId);
|
||||
|
||||
// targetPath is root-first, so iterate backwards to find deepest common ancestor
|
||||
let commonAncestorId: string | null = null;
|
||||
for (let i = targetPath.length - 1; i >= 0; i--) {
|
||||
if (oldPath.has(targetPath[i].id)) {
|
||||
commonAncestorId = targetPath[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect entries from old leaf back to common ancestor
|
||||
const entries: SessionEntry[] = [];
|
||||
let current: string | null = oldLeafId;
|
||||
|
||||
while (current && current !== commonAncestorId) {
|
||||
const entry = session.getEntry(current);
|
||||
if (!entry) break;
|
||||
entries.push(entry);
|
||||
current = entry.parentId;
|
||||
}
|
||||
|
||||
// Reverse to get chronological order
|
||||
entries.reverse();
|
||||
|
||||
return { entries, commonAncestorId };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entry to Message Conversion
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract AgentMessage from a session entry.
|
||||
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
|
||||
*/
|
||||
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
// Skip tool results - context is in assistant's tool call
|
||||
if (entry.message.role === "toolResult") return undefined;
|
||||
return entry.message;
|
||||
|
||||
case "custom_message":
|
||||
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
|
||||
case "branch_summary":
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
|
||||
case "compaction":
|
||||
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
|
||||
|
||||
// These don't contribute to conversation content
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "custom":
|
||||
case "label":
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare entries for summarization with token budget.
|
||||
*
|
||||
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
|
||||
* This ensures we keep the most recent context when the branch is too long.
|
||||
*
|
||||
* Also collects file operations from:
|
||||
* - Tool calls in assistant messages
|
||||
* - Existing branch_summary entries' details (for cumulative tracking)
|
||||
*
|
||||
* @param entries - Entries in chronological order
|
||||
* @param tokenBudget - Maximum tokens to include (0 = no limit)
|
||||
*/
|
||||
export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {
|
||||
const messages: AgentMessage[] = [];
|
||||
const fileOps = createFileOps();
|
||||
let totalTokens = 0;
|
||||
|
||||
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
|
||||
// This ensures we capture cumulative file tracking from nested branch summaries
|
||||
// Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
|
||||
const details = entry.details as BranchSummaryDetails;
|
||||
if (Array.isArray(details.readFiles)) {
|
||||
for (const f of details.readFiles) fileOps.read.add(f);
|
||||
}
|
||||
if (Array.isArray(details.modifiedFiles)) {
|
||||
// Modified files go into both edited and written for proper deduplication
|
||||
for (const f of details.modifiedFiles) {
|
||||
fileOps.edited.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: walk from newest to oldest, adding messages until token budget
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
const message = getMessageFromEntry(entry);
|
||||
if (!message) continue;
|
||||
|
||||
// Extract file ops from assistant messages (tool calls)
|
||||
extractFileOpsFromMessage(message, fileOps);
|
||||
|
||||
const tokens = estimateTokens(message);
|
||||
|
||||
// Check budget before adding
|
||||
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
|
||||
// If this is a summary entry, try to fit it anyway as it's important context
|
||||
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
||||
if (totalTokens < tokenBudget * 0.9) {
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
}
|
||||
// Stop - we've hit the budget
|
||||
break;
|
||||
}
|
||||
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
|
||||
return { messages, fileOps, totalTokens };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summary Generation
|
||||
// ============================================================================
|
||||
|
||||
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
|
||||
Summary of that exploration:
|
||||
|
||||
`;
|
||||
|
||||
const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What was the user trying to accomplish in this branch?]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Work that was started but not finished]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [What should happen next to continue this work]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/**
|
||||
* Generate a summary of abandoned branch entries.
|
||||
*
|
||||
* @param entries - Session entries to summarize (chronological order)
|
||||
* @param options - Generation options
|
||||
*/
|
||||
export async function generateBranchSummary(
|
||||
entries: SessionEntry[],
|
||||
options: GenerateBranchSummaryOptions,
|
||||
): Promise<BranchSummaryResult> {
|
||||
const { model, apiKey, signal, customInstructions, reserveTokens = 16384 } = options;
|
||||
|
||||
// Token budget = context window minus reserved space for prompt + response
|
||||
const contextWindow = model.contextWindow || 128000;
|
||||
const tokenBudget = contextWindow - reserveTokens;
|
||||
|
||||
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { summary: "No content to summarize" };
|
||||
}
|
||||
|
||||
// Transform to LLM-compatible messages, then serialize to text
|
||||
// Serialization prevents the model from treating it as a conversation to continue
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
// Build prompt
|
||||
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`;
|
||||
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
// Call LLM for summarization
|
||||
const response = await completeSimple(
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ apiKey, signal, maxTokens: 2048 },
|
||||
);
|
||||
|
||||
// Check if aborted or errored
|
||||
if (response.stopReason === "aborted") {
|
||||
return { aborted: true };
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
return { error: response.errorMessage || "Summarization failed" };
|
||||
}
|
||||
|
||||
let summary = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
// Prepend preamble to provide context about the branch summary
|
||||
summary = BRANCH_SUMMARY_PREAMBLE + summary;
|
||||
|
||||
// Compute file lists and append to summary
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
return {
|
||||
summary: summary || "No summary generated",
|
||||
readFiles,
|
||||
modifiedFiles,
|
||||
};
|
||||
}
|
||||
742
packages/coding-agent/src/core/compaction/compaction.ts
Normal file
742
packages/coding-agent/src/core/compaction/compaction.ts
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
/**
|
||||
* Context compaction for long sessions.
|
||||
*
|
||||
* Pure functions for compaction logic. The session manager handles I/O,
|
||||
* and after compaction the session is reloaded.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
||||
import { complete, completeSimple } from "@mariozechner/pi-ai";
|
||||
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js";
|
||||
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
SUMMARIZATION_SYSTEM_PROMPT,
|
||||
serializeConversation,
|
||||
} from "./utils.js";
|
||||
|
||||
// ============================================================================
|
||||
// File Operation Tracking
|
||||
// ============================================================================
|
||||
|
||||
/** Details stored in CompactionEntry.details for file tracking */
|
||||
export interface CompactionDetails {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file operations from messages and previous compaction entries.
|
||||
*/
|
||||
function extractFileOperations(
|
||||
messages: AgentMessage[],
|
||||
entries: SessionEntry[],
|
||||
prevCompactionIndex: number,
|
||||
): FileOperations {
|
||||
const fileOps = createFileOps();
|
||||
|
||||
// Collect from previous compaction's details (if pi-generated)
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||
if (!prevCompaction.fromHook && prevCompaction.details) {
|
||||
const details = prevCompaction.details as CompactionDetails;
|
||||
if (Array.isArray(details.readFiles)) {
|
||||
for (const f of details.readFiles) fileOps.read.add(f);
|
||||
}
|
||||
if (Array.isArray(details.modifiedFiles)) {
|
||||
for (const f of details.modifiedFiles) fileOps.edited.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from tool calls in messages
|
||||
for (const msg of messages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
|
||||
return fileOps;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Extraction
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract AgentMessage from an entry if it produces one.
|
||||
* Returns undefined for entries that don't contribute to LLM context.
|
||||
*/
|
||||
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
||||
if (entry.type === "message") {
|
||||
return entry.message;
|
||||
}
|
||||
if (entry.type === "custom_message") {
|
||||
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
}
|
||||
if (entry.type === "branch_summary") {
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
|
||||
export interface CompactionResult<T = unknown> {
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
||||
details?: T;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionSettings {
|
||||
enabled: boolean;
|
||||
reserveTokens: number;
|
||||
keepRecentTokens: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 16384,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Token calculation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate total context tokens from usage.
|
||||
* Uses the native totalTokens field when available, falls back to computing from components.
|
||||
*/
|
||||
export function calculateContextTokens(usage: Usage): number {
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage from an assistant message if available.
|
||||
* Skips aborted and error messages as they don't have valid usage data.
|
||||
*/
|
||||
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
|
||||
if (msg.role === "assistant" && "usage" in msg) {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
|
||||
return assistantMsg.usage;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last non-aborted assistant message usage from session entries.
|
||||
*/
|
||||
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const usage = getAssistantUsage(entry.message);
|
||||
if (usage) return usage;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compaction should trigger based on context usage.
|
||||
*/
|
||||
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
|
||||
if (!settings.enabled) return false;
|
||||
return contextTokens > contextWindow - settings.reserveTokens;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cut point detection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using chars/4 heuristic.
|
||||
* This is conservative (overestimates tokens).
|
||||
*/
|
||||
export function estimateTokens(message: AgentMessage): number {
|
||||
let chars = 0;
|
||||
|
||||
switch (message.role) {
|
||||
case "user": {
|
||||
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (typeof content === "string") {
|
||||
chars = content.length;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "assistant": {
|
||||
const assistant = message as AssistantMessage;
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "text") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
} else if (block.type === "toolCall") {
|
||||
chars += block.name.length + JSON.stringify(block.arguments).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "hookMessage":
|
||||
case "toolResult": {
|
||||
if (typeof message.content === "string") {
|
||||
chars = message.content.length;
|
||||
} else {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "bashExecution": {
|
||||
chars = message.command.length + message.output.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "branchSummary":
|
||||
case "compactionSummary": {
|
||||
chars = message.summary.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
|
||||
* Never cut at tool results (they must follow their tool call).
|
||||
* When we cut at an assistant message with tool calls, its tool results follow it
|
||||
* and will be kept.
|
||||
* BashExecutionMessage is treated like a user message (user-initiated context).
|
||||
*/
|
||||
function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
|
||||
const cutPoints: number[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const entry = entries[i];
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const role = entry.message.role;
|
||||
switch (role) {
|
||||
case "bashExecution":
|
||||
case "hookMessage":
|
||||
case "branchSummary":
|
||||
case "compactionSummary":
|
||||
case "user":
|
||||
case "assistant":
|
||||
cutPoints.push(i);
|
||||
break;
|
||||
case "toolResult":
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "compaction":
|
||||
case "branch_summary":
|
||||
case "custom":
|
||||
case "custom_message":
|
||||
case "label":
|
||||
}
|
||||
// branch_summary and custom_message are user-role messages, valid cut points
|
||||
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
||||
cutPoints.push(i);
|
||||
}
|
||||
}
|
||||
return cutPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
|
||||
* Returns -1 if no turn start found before the index.
|
||||
* BashExecutionMessage is treated like a user message for turn boundaries.
|
||||
*/
|
||||
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
|
||||
for (let i = entryIndex; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
// branch_summary and custom_message are user-role messages, can start a turn
|
||||
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
||||
return i;
|
||||
}
|
||||
if (entry.type === "message") {
|
||||
const role = entry.message.role;
|
||||
if (role === "user" || role === "bashExecution") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface CutPointResult {
|
||||
/** Index of first entry to keep */
|
||||
firstKeptEntryIndex: number;
|
||||
/** Index of user message that starts the turn being split, or -1 if not splitting */
|
||||
turnStartIndex: number;
|
||||
/** Whether this cut splits a turn (cut point is not a user message) */
|
||||
isSplitTurn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
|
||||
*
|
||||
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
|
||||
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
|
||||
*
|
||||
* Can cut at user OR assistant messages (never tool results). When cutting at an
|
||||
* assistant message with tool calls, its tool results come after and will be kept.
|
||||
*
|
||||
* Returns CutPointResult with:
|
||||
* - firstKeptEntryIndex: the entry index to start keeping from
|
||||
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
|
||||
* - isSplitTurn: whether we're cutting in the middle of a turn
|
||||
*
|
||||
* Only considers entries between `startIndex` and `endIndex` (exclusive).
|
||||
*/
|
||||
export function findCutPoint(
|
||||
entries: SessionEntry[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
keepRecentTokens: number,
|
||||
): CutPointResult {
|
||||
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
|
||||
|
||||
if (cutPoints.length === 0) {
|
||||
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
||||
}
|
||||
|
||||
// Walk backwards from newest, accumulating estimated message sizes
|
||||
let accumulatedTokens = 0;
|
||||
let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
|
||||
|
||||
for (let i = endIndex - 1; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
// Estimate this message's size
|
||||
const messageTokens = estimateTokens(entry.message);
|
||||
accumulatedTokens += messageTokens;
|
||||
|
||||
// Check if we've exceeded the budget
|
||||
if (accumulatedTokens >= keepRecentTokens) {
|
||||
// Find the closest valid cut point at or after this entry
|
||||
for (let c = 0; c < cutPoints.length; c++) {
|
||||
if (cutPoints[c] >= i) {
|
||||
cutIndex = cutPoints[c];
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
|
||||
while (cutIndex > startIndex) {
|
||||
const prevEntry = entries[cutIndex - 1];
|
||||
// Stop at session header or compaction boundaries
|
||||
if (prevEntry.type === "compaction") {
|
||||
break;
|
||||
}
|
||||
if (prevEntry.type === "message") {
|
||||
// Stop if we hit any message
|
||||
break;
|
||||
}
|
||||
// Include this non-message entry (bash, settings change, etc.)
|
||||
cutIndex--;
|
||||
}
|
||||
|
||||
// Determine if this is a split turn
|
||||
const cutEntry = entries[cutIndex];
|
||||
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
||||
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
|
||||
return {
|
||||
firstKeptEntryIndex: cutIndex,
|
||||
turnStartIndex,
|
||||
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization
|
||||
// ============================================================================
|
||||
|
||||
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned by user]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [Ordered list of what should happen next]
|
||||
|
||||
## Critical Context
|
||||
- [Any data, examples, or references needed to continue]
|
||||
- [Or "(none)" if not applicable]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
|
||||
|
||||
Update the existing structured summary with new information. RULES:
|
||||
- PRESERVE all existing information from the previous summary
|
||||
- ADD new progress, decisions, and context from the new messages
|
||||
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
|
||||
- UPDATE "Next Steps" based on what was accomplished
|
||||
- PRESERVE exact file paths, function names, and error messages
|
||||
- If something is no longer relevant, you may remove it
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[Preserve existing goals, add new ones if the task expanded]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Preserve existing, add new ones discovered]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Include previously done items AND newly completed items]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work - update based on progress]
|
||||
|
||||
### Blocked
|
||||
- [Current blockers - remove if resolved]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
|
||||
|
||||
## Next Steps
|
||||
1. [Update based on current state]
|
||||
|
||||
## Critical Context
|
||||
- [Preserve important context, add new if needed]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/**
|
||||
* Generate a summary of the conversation using the LLM.
|
||||
* If previousSummary is provided, uses the update prompt to merge.
|
||||
*/
|
||||
export async function generateSummary(
|
||||
currentMessages: AgentMessage[],
|
||||
model: Model<any>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.8 * reserveTokens);
|
||||
|
||||
// Use update prompt if we have a previous summary, otherwise initial prompt
|
||||
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
|
||||
if (customInstructions) {
|
||||
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
|
||||
}
|
||||
|
||||
// Serialize conversation to text so model doesn't try to continue it
|
||||
// Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.)
|
||||
const llmMessages = convertToLlm(currentMessages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
// Build the prompt with conversation wrapped in tags
|
||||
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
||||
if (previousSummary) {
|
||||
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
|
||||
}
|
||||
promptText += basePrompt;
|
||||
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await completeSimple(
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ maxTokens, signal, apiKey, reasoning: "high" },
|
||||
);
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
|
||||
}
|
||||
|
||||
const textContent = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
return textContent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Compaction Preparation (for hooks)
|
||||
// ============================================================================
|
||||
|
||||
export interface CompactionPreparation {
|
||||
/** UUID of first entry to keep */
|
||||
firstKeptEntryId: string;
|
||||
/** Messages that will be summarized and discarded */
|
||||
messagesToSummarize: AgentMessage[];
|
||||
/** Messages that will be turned into turn prefix summary (if splitting) */
|
||||
turnPrefixMessages: AgentMessage[];
|
||||
/** Whether this is a split turn (cut point in middle of turn) */
|
||||
isSplitTurn: boolean;
|
||||
tokensBefore: number;
|
||||
/** Summary from previous compaction, for iterative update */
|
||||
previousSummary?: string;
|
||||
/** File operations extracted from messagesToSummarize */
|
||||
fileOps: FileOperations;
|
||||
/** Compaction settions from settings.jsonl */
|
||||
settings: CompactionSettings;
|
||||
}
|
||||
|
||||
export function prepareCompaction(
|
||||
pathEntries: SessionEntry[],
|
||||
settings: CompactionSettings,
|
||||
): CompactionPreparation | undefined {
|
||||
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
||||
if (pathEntries[i].type === "compaction") {
|
||||
prevCompactionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const boundaryStart = prevCompactionIndex + 1;
|
||||
const boundaryEnd = pathEntries.length;
|
||||
|
||||
const lastUsage = getLastAssistantUsage(pathEntries);
|
||||
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
|
||||
|
||||
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||
|
||||
// Get UUID of first kept entry
|
||||
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
|
||||
if (!firstKeptEntry?.id) {
|
||||
return undefined; // Session needs migration
|
||||
}
|
||||
const firstKeptEntryId = firstKeptEntry.id;
|
||||
|
||||
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
||||
|
||||
// Messages to summarize (will be discarded after summary)
|
||||
const messagesToSummarize: AgentMessage[] = [];
|
||||
for (let i = boundaryStart; i < historyEnd; i++) {
|
||||
const msg = getMessageFromEntry(pathEntries[i]);
|
||||
if (msg) messagesToSummarize.push(msg);
|
||||
}
|
||||
|
||||
// Messages for turn prefix summary (if splitting a turn)
|
||||
const turnPrefixMessages: AgentMessage[] = [];
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
|
||||
const msg = getMessageFromEntry(pathEntries[i]);
|
||||
if (msg) turnPrefixMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Get previous summary for iterative update
|
||||
let previousSummary: string | undefined;
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
|
||||
previousSummary = prevCompaction.summary;
|
||||
}
|
||||
|
||||
// Extract file operations from messages and previous compaction
|
||||
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
||||
|
||||
// Also extract file ops from turn prefix if splitting
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (const msg of turnPrefixMessages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
isSplitTurn: cutPoint.isSplitTurn,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main compaction function
|
||||
// ============================================================================
|
||||
|
||||
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
|
||||
|
||||
Summarize the prefix to provide context for the retained suffix:
|
||||
|
||||
## Original Request
|
||||
[What did the user ask for in this turn?]
|
||||
|
||||
## Early Progress
|
||||
- [Key decisions and work done in the prefix]
|
||||
|
||||
## Context for Suffix
|
||||
- [Information needed to understand the retained recent work]
|
||||
|
||||
Be concise. Focus on what's needed to understand the kept suffix.`;
|
||||
|
||||
/**
|
||||
* Generate summaries for compaction using prepared data.
|
||||
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
|
||||
*
|
||||
* @param preparation - Pre-calculated preparation from prepareCompaction()
|
||||
* @param customInstructions - Optional custom focus for the summary
|
||||
*/
|
||||
export async function compact(
|
||||
preparation: CompactionPreparation,
|
||||
model: Model<any>,
|
||||
apiKey: string,
|
||||
customInstructions?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
const {
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
isSplitTurn,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
} = preparation;
|
||||
|
||||
// Generate summaries (can be parallel if both needed) and merge into one
|
||||
let summary: string;
|
||||
|
||||
if (isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
// Generate both summaries in parallel
|
||||
const [historyResult, turnPrefixResult] = await Promise.all([
|
||||
messagesToSummarize.length > 0
|
||||
? generateSummary(
|
||||
messagesToSummarize,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
)
|
||||
: Promise.resolve("No prior history."),
|
||||
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
|
||||
]);
|
||||
// Merge into single summary
|
||||
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
|
||||
} else {
|
||||
// Just generate history summary
|
||||
summary = await generateSummary(
|
||||
messagesToSummarize,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
);
|
||||
}
|
||||
|
||||
// Compute file lists and append to summary
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
if (!firstKeptEntryId) {
|
||||
throw new Error("First kept entry has no UUID - session may need migration");
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
details: { readFiles, modifiedFiles } as CompactionDetails,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary for a turn prefix (when splitting a turn).
|
||||
*/
|
||||
async function generateTurnPrefixSummary(
|
||||
messages: AgentMessage[],
|
||||
model: Model<any>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
||||
|
||||
const transformedMessages = convertToLlm(messages);
|
||||
const summarizationMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
|
||||
}
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
7
packages/coding-agent/src/core/compaction/index.ts
Normal file
7
packages/coding-agent/src/core/compaction/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Compaction and summarization utilities.
|
||||
*/
|
||||
|
||||
export * from "./branch-summarization.js";
|
||||
export * from "./compaction.js";
|
||||
export * from "./utils.js";
|
||||
154
packages/coding-agent/src/core/compaction/utils.ts
Normal file
154
packages/coding-agent/src/core/compaction/utils.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Shared utilities for compaction and branch summarization.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Message } from "@mariozechner/pi-ai";
|
||||
|
||||
// ============================================================================
|
||||
// File Operation Tracking
|
||||
// ============================================================================
|
||||
|
||||
export interface FileOperations {
|
||||
read: Set<string>;
|
||||
written: Set<string>;
|
||||
edited: Set<string>;
|
||||
}
|
||||
|
||||
export function createFileOps(): FileOperations {
|
||||
return {
|
||||
read: new Set(),
|
||||
written: new Set(),
|
||||
edited: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file operations from tool calls in an assistant message.
|
||||
*/
|
||||
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
|
||||
if (message.role !== "assistant") return;
|
||||
if (!("content" in message) || !Array.isArray(message.content)) return;
|
||||
|
||||
for (const block of message.content) {
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
if (!("type" in block) || block.type !== "toolCall") continue;
|
||||
if (!("arguments" in block) || !("name" in block)) continue;
|
||||
|
||||
const args = block.arguments as Record<string, unknown> | undefined;
|
||||
if (!args) continue;
|
||||
|
||||
const path = typeof args.path === "string" ? args.path : undefined;
|
||||
if (!path) continue;
|
||||
|
||||
switch (block.name) {
|
||||
case "read":
|
||||
fileOps.read.add(path);
|
||||
break;
|
||||
case "write":
|
||||
fileOps.written.add(path);
|
||||
break;
|
||||
case "edit":
|
||||
fileOps.edited.add(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute final file lists from file operations.
|
||||
* Returns readFiles (files only read, not modified) and modifiedFiles.
|
||||
*/
|
||||
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
|
||||
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
||||
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
||||
const modifiedFiles = [...modified].sort();
|
||||
return { readFiles: readOnly, modifiedFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file operations as XML tags for summary.
|
||||
*/
|
||||
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
|
||||
const sections: string[] = [];
|
||||
if (readFiles.length > 0) {
|
||||
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
||||
}
|
||||
if (modifiedFiles.length > 0) {
|
||||
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
||||
}
|
||||
if (sections.length === 0) return "";
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Serialization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Serialize LLM messages to text for summarization.
|
||||
* This prevents the model from treating it as a conversation to continue.
|
||||
* Call convertToLlm() first to handle custom message types.
|
||||
*/
|
||||
export function serializeConversation(messages: Message[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const content =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (content) parts.push(`[User]: ${content}`);
|
||||
} else if (msg.role === "assistant") {
|
||||
const textParts: string[] = [];
|
||||
const thinkingParts: string[] = [];
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text") {
|
||||
textParts.push(block.text);
|
||||
} else if (block.type === "thinking") {
|
||||
thinkingParts.push(block.thinking);
|
||||
} else if (block.type === "toolCall") {
|
||||
const args = block.arguments as Record<string, unknown>;
|
||||
const argsStr = Object.entries(args)
|
||||
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
||||
.join(", ");
|
||||
toolCalls.push(`${block.name}(${argsStr})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (thinkingParts.length > 0) {
|
||||
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
|
||||
}
|
||||
if (textParts.length > 0) {
|
||||
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
|
||||
}
|
||||
} else if (msg.role === "toolResult") {
|
||||
const content = msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (content) {
|
||||
parts.push(`[Tool result]: ${content}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization System Prompt
|
||||
// ============================================================================
|
||||
|
||||
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
|
||||
|
|
@ -4,14 +4,18 @@
|
|||
|
||||
export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js";
|
||||
export type {
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
CustomAgentTool,
|
||||
CustomTool,
|
||||
CustomToolAPI,
|
||||
CustomToolContext,
|
||||
CustomToolFactory,
|
||||
CustomToolResult,
|
||||
CustomToolSessionEvent,
|
||||
CustomToolsLoadResult,
|
||||
CustomToolUIContext,
|
||||
ExecResult,
|
||||
LoadedCustomTool,
|
||||
RenderResultOptions,
|
||||
SessionEvent,
|
||||
ToolAPI,
|
||||
ToolUIContext,
|
||||
} from "./types.js";
|
||||
export { wrapCustomTool, wrapCustomTools } from "./wrapper.js";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
* for custom tools that depend on pi packages.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as os from "node:os";
|
||||
|
|
@ -15,15 +14,10 @@ import * as path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { createJiti } from "jiti";
|
||||
import { getAgentDir, isBunBinary } from "../../config.js";
|
||||
import type { ExecOptions } from "../exec.js";
|
||||
import { execCommand } from "../exec.js";
|
||||
import type { HookUIContext } from "../hooks/types.js";
|
||||
import type {
|
||||
CustomToolFactory,
|
||||
CustomToolsLoadResult,
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
LoadedCustomTool,
|
||||
ToolAPI,
|
||||
} from "./types.js";
|
||||
import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js";
|
||||
|
||||
// Create require function to resolve module paths at runtime
|
||||
const require = createRequire(import.meta.url);
|
||||
|
|
@ -87,97 +81,18 @@ function resolveToolPath(toolPath: string, cwd: string): string {
|
|||
return path.resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return stdout/stderr/code.
|
||||
* Supports cancellation via AbortSignal and timeout.
|
||||
*/
|
||||
async function execCommand(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, {
|
||||
cwd,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let killed = false;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const killProcess = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
proc.kill("SIGTERM");
|
||||
// Force kill after 5 seconds if SIGTERM doesn't work
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle abort signal
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
killProcess();
|
||||
} else {
|
||||
options.signal.addEventListener("abort", killProcess, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle timeout
|
||||
if (options?.timeout && options.timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
killProcess();
|
||||
}, options.timeout);
|
||||
}
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
code: code ?? 0,
|
||||
killed,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener("abort", killProcess);
|
||||
}
|
||||
resolve({
|
||||
stdout,
|
||||
stderr: stderr || err.message,
|
||||
code: 1,
|
||||
killed,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a no-op UI context for headless modes.
|
||||
*/
|
||||
function createNoOpUIContext(): HookUIContext {
|
||||
return {
|
||||
select: async () => null,
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => null,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +106,7 @@ function createNoOpUIContext(): HookUIContext {
|
|||
*/
|
||||
async function loadToolWithBun(
|
||||
resolvedPath: string,
|
||||
sharedApi: ToolAPI,
|
||||
sharedApi: CustomToolAPI,
|
||||
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
|
||||
try {
|
||||
// Try to import directly - will work for tools without @mariozechner/* imports
|
||||
|
|
@ -236,7 +151,7 @@ async function loadToolWithBun(
|
|||
async function loadTool(
|
||||
toolPath: string,
|
||||
cwd: string,
|
||||
sharedApi: ToolAPI,
|
||||
sharedApi: CustomToolAPI,
|
||||
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
|
||||
const resolvedPath = resolveToolPath(toolPath, cwd);
|
||||
|
||||
|
|
@ -296,9 +211,10 @@ export async function loadCustomTools(
|
|||
const seenNames = new Set<string>(builtInToolNames);
|
||||
|
||||
// Shared API object - all tools get the same instance
|
||||
const sharedApi: ToolAPI = {
|
||||
const sharedApi: CustomToolAPI = {
|
||||
cwd,
|
||||
exec: (command: string, args: string[], options?: ExecOptions) => execCommand(command, args, cwd, options),
|
||||
exec: (command: string, args: string[], options?: ExecOptions) =>
|
||||
execCommand(command, args, options?.cwd ?? cwd, options),
|
||||
ui: createNoOpUIContext(),
|
||||
hasUI: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,56 +5,56 @@
|
|||
* They can provide custom rendering for tool calls and results in the TUI.
|
||||
*/
|
||||
|
||||
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-ai";
|
||||
import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||
import type { HookUIContext } from "../hooks/types.js";
|
||||
import type { SessionEntry } from "../session-manager.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type { ReadonlySessionManager } from "../session-manager.js";
|
||||
|
||||
/** Alias for clarity */
|
||||
export type ToolUIContext = HookUIContext;
|
||||
export type CustomToolUIContext = HookUIContext;
|
||||
|
||||
/** Re-export for custom tools to use in execute signature */
|
||||
export type { AgentToolUpdateCallback };
|
||||
export type { AgentToolResult, AgentToolUpdateCallback };
|
||||
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
/** True if the process was killed due to signal or timeout */
|
||||
killed?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecOptions {
|
||||
/** AbortSignal to cancel the process */
|
||||
signal?: AbortSignal;
|
||||
/** Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
}
|
||||
// Re-export for backward compatibility
|
||||
export type { ExecOptions, ExecResult } from "../exec.js";
|
||||
|
||||
/** API passed to custom tool factory (stable across session changes) */
|
||||
export interface ToolAPI {
|
||||
export interface CustomToolAPI {
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Execute a command */
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
/** UI methods for user interaction (select, confirm, input, notify) */
|
||||
ui: ToolUIContext;
|
||||
/** UI methods for user interaction (select, confirm, input, notify, custom) */
|
||||
ui: CustomToolUIContext;
|
||||
/** Whether UI is available (false in print/RPC mode) */
|
||||
hasUI: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to tool execute and onSession callbacks.
|
||||
* Provides access to session state and model information.
|
||||
*/
|
||||
export interface CustomToolContext {
|
||||
/** Session manager (read-only) */
|
||||
sessionManager: ReadonlySessionManager;
|
||||
/** Model registry - use for API key resolution and model retrieval */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Current model (may be undefined if no model is selected yet) */
|
||||
model: Model<any> | undefined;
|
||||
}
|
||||
|
||||
/** Session event passed to onSession callback */
|
||||
export interface SessionEvent {
|
||||
/** All session entries (including pre-compaction history) */
|
||||
entries: SessionEntry[];
|
||||
/** Current session file path, or null in --no-session mode */
|
||||
sessionFile: string | null;
|
||||
/** Previous session file path, or null for "start" and "new" */
|
||||
previousSessionFile: string | null;
|
||||
export interface CustomToolSessionEvent {
|
||||
/** Reason for the session event */
|
||||
reason: "start" | "switch" | "branch" | "new";
|
||||
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
|
||||
/** Previous session file path, or undefined for "start", "new", and "shutdown" */
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Rendering options passed to renderResult */
|
||||
|
|
@ -65,60 +65,89 @@ export interface RenderResultOptions {
|
|||
isPartial: boolean;
|
||||
}
|
||||
|
||||
export type CustomToolResult<TDetails = any> = AgentToolResult<TDetails>;
|
||||
|
||||
/**
|
||||
* Custom tool with optional lifecycle and rendering methods.
|
||||
* Custom tool definition.
|
||||
*
|
||||
* The execute signature inherited from AgentTool includes an optional onUpdate callback
|
||||
* for streaming progress updates during long-running operations:
|
||||
* - The callback emits partial results to subscribers (e.g. TUI/RPC), not to the LLM.
|
||||
* - Partial updates should use the same TDetails type as the final result (use a union if needed).
|
||||
* Custom tools are standalone - they don't extend AgentTool directly.
|
||||
* When loaded, they are wrapped in an AgentTool for the agent to use.
|
||||
*
|
||||
* The execute callback receives a ToolContext with access to session state,
|
||||
* model registry, and current model.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* type Details =
|
||||
* | { status: "running"; step: number; total: number }
|
||||
* | { status: "done"; count: number };
|
||||
* const factory: CustomToolFactory = (pi) => ({
|
||||
* name: "my_tool",
|
||||
* label: "My Tool",
|
||||
* description: "Does something useful",
|
||||
* parameters: Type.Object({ input: Type.String() }),
|
||||
*
|
||||
* async execute(toolCallId, params, signal, onUpdate) {
|
||||
* const items = params.items || [];
|
||||
* for (let i = 0; i < items.length; i++) {
|
||||
* onUpdate?.({
|
||||
* content: [{ type: "text", text: `Step ${i + 1}/${items.length}...` }],
|
||||
* details: { status: "running", step: i + 1, total: items.length },
|
||||
* });
|
||||
* await processItem(items[i], signal);
|
||||
* async execute(toolCallId, params, onUpdate, ctx, signal) {
|
||||
* // Access session state via ctx.sessionManager
|
||||
* // Access model registry via ctx.modelRegistry
|
||||
* // Current model via ctx.model
|
||||
* return { content: [{ type: "text", text: "Done" }] };
|
||||
* },
|
||||
*
|
||||
* onSession(event, ctx) {
|
||||
* if (event.reason === "shutdown") {
|
||||
* // Cleanup
|
||||
* }
|
||||
* // Reconstruct state from ctx.sessionManager.getEntries()
|
||||
* }
|
||||
* return { content: [{ type: "text", text: "Done" }], details: { status: "done", count: items.length } };
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Progress updates are rendered via renderResult with isPartial: true.
|
||||
*/
|
||||
export interface CustomAgentTool<TParams extends TSchema = TSchema, TDetails = any>
|
||||
extends AgentTool<TParams, TDetails> {
|
||||
/** Called on session start/switch/branch/clear - use to reconstruct state from entries */
|
||||
onSession?: (event: SessionEvent) => void | Promise<void>;
|
||||
export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
|
||||
/** Tool name (used in LLM tool calls) */
|
||||
name: string;
|
||||
/** Human-readable label for UI */
|
||||
label: string;
|
||||
/** Description for LLM */
|
||||
description: string;
|
||||
/** Parameter schema (TypeBox) */
|
||||
parameters: TParams;
|
||||
|
||||
/**
|
||||
* Execute the tool.
|
||||
* @param toolCallId - Unique ID for this tool call
|
||||
* @param params - Parsed parameters matching the schema
|
||||
* @param onUpdate - Callback for streaming partial results (for UI, not LLM)
|
||||
* @param ctx - Context with session manager, model registry, and current model
|
||||
* @param signal - Optional abort signal for cancellation
|
||||
*/
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Static<TParams>,
|
||||
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
||||
ctx: CustomToolContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
|
||||
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
|
||||
onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
|
||||
/** Custom rendering for tool call display - return a Component */
|
||||
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
|
||||
|
||||
/** Custom rendering for tool result display - return a Component */
|
||||
renderResult?: (result: AgentToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
|
||||
/** Called when session ends - cleanup resources */
|
||||
dispose?: () => Promise<void> | void;
|
||||
renderResult?: (result: CustomToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
|
||||
}
|
||||
|
||||
/** Factory function that creates a custom tool or array of tools */
|
||||
export type CustomToolFactory = (
|
||||
pi: ToolAPI,
|
||||
) => CustomAgentTool<any> | CustomAgentTool[] | Promise<CustomAgentTool | CustomAgentTool[]>;
|
||||
pi: CustomToolAPI,
|
||||
) => CustomTool<any, any> | CustomTool<any, any>[] | Promise<CustomTool<any, any> | CustomTool<any, any>[]>;
|
||||
|
||||
/** Loaded custom tool with metadata */
|
||||
/** Loaded custom tool with metadata and wrapped AgentTool */
|
||||
export interface LoadedCustomTool {
|
||||
/** Original path (as specified) */
|
||||
path: string;
|
||||
/** Resolved absolute path */
|
||||
resolvedPath: string;
|
||||
/** The tool instance */
|
||||
tool: CustomAgentTool;
|
||||
/** The original custom tool instance */
|
||||
tool: CustomTool;
|
||||
}
|
||||
|
||||
/** Result from loading custom tools */
|
||||
|
|
@ -126,5 +155,5 @@ export interface CustomToolsLoadResult {
|
|||
tools: LoadedCustomTool[];
|
||||
errors: Array<{ path: string; error: string }>;
|
||||
/** Update the UI context for all loaded tools. Call when mode initializes. */
|
||||
setUIContext(uiContext: ToolUIContext, hasUI: boolean): void;
|
||||
setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue