Compare commits
11 Commits
7adb8e7634
...
5f7a53ed21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f7a53ed21 | ||
|
|
c5295773a1 | ||
|
|
e25415dd5f | ||
|
|
79db9d62ef | ||
|
|
fe6b85b32c | ||
|
|
322759a3f0 | ||
|
|
76131673d3 | ||
|
|
f13e6a88ab | ||
|
|
401017a3e8 | ||
|
|
3d5cbe98c3 | ||
|
|
116bffeb88 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -38,4 +38,10 @@ todo.md
|
||||
plans/
|
||||
.pi/hf-sessions/
|
||||
.pi/hf-sessions-backup/
|
||||
.pi/agent/
|
||||
.pi/sessions/
|
||||
.pi/extensions/webui/.webui.pid
|
||||
.pi/extensions/webui/.webui.log
|
||||
tmp/
|
||||
bun.lock
|
||||
collect.sh
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Container, Text } from "@earendil-works/pi-tui";
|
||||
|
||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||
|
||||
type PromptMatch = {
|
||||
kind: "pr" | "issue";
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GhMetadata = {
|
||||
title?: string;
|
||||
author?: {
|
||||
login?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||
if (prMatch?.[1]) {
|
||||
return { kind: "pr", url: prMatch[1].trim() };
|
||||
}
|
||||
|
||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||
if (issueMatch?.[1]) {
|
||||
return { kind: "issue", url: issueMatch[1].trim() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchGhMetadata(
|
||||
pi: ExtensionAPI,
|
||||
kind: PromptMatch["kind"],
|
||||
url: string,
|
||||
): Promise<GhMetadata | undefined> {
|
||||
const args =
|
||||
kind === "pr" ? ["pr", "view", url, "--json", "title,author"] : ["issue", "view", url, "--json", "title,author"];
|
||||
|
||||
try {
|
||||
const result = await pi.exec("gh", args);
|
||||
if (result.code !== 0 || !result.stdout) return undefined;
|
||||
return JSON.parse(result.stdout) as GhMetadata;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||
if (!author) return undefined;
|
||||
const name = author.name?.trim();
|
||||
const login = author.login?.trim();
|
||||
if (name && login) return `${name} (@${login})`;
|
||||
if (login) return `@${login}`;
|
||||
if (name) return name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
const setWidget = (ctx: ExtensionContext, match: PromptMatch, title?: string, authorText?: string) => {
|
||||
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
|
||||
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
|
||||
const urlLine = thm.fg("dim", match.url);
|
||||
|
||||
const lines = [titleText];
|
||||
if (authorLine) lines.push(authorLine);
|
||||
lines.push(urlLine);
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
||||
container.addChild(new Text(lines.join("\n"), 1, 0));
|
||||
return container;
|
||||
});
|
||||
};
|
||||
|
||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
||||
const label = match.kind === "pr" ? "PR" : "Issue";
|
||||
const trimmedTitle = title?.trim();
|
||||
const fallbackName = `${label}: ${match.url}`;
|
||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||
const currentName = pi.getSessionName()?.trim();
|
||||
if (!currentName) {
|
||||
pi.setSessionName(desiredName);
|
||||
return;
|
||||
}
|
||||
if (currentName === match.url || currentName === fallbackName) {
|
||||
pi.setSessionName(desiredName);
|
||||
}
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
const match = extractPromptMatch(event.prompt);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
|
||||
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
return (
|
||||
content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const lastMatch = [...entries].reverse().find((entry) => {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") return false;
|
||||
const text = getUserText(entry.message.content);
|
||||
return !!extractPromptMatch(text);
|
||||
});
|
||||
|
||||
const content =
|
||||
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
|
||||
const text = getUserText(content);
|
||||
const match = text ? extractPromptMatch(text) : undefined;
|
||||
if (!match) {
|
||||
ctx.ui.setWidget("prompt-url", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Redraws Extension
|
||||
*
|
||||
* Exposes /tui to show TUI redraw stats.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("tui", {
|
||||
description: "Show TUI stats",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
let redraws = 0;
|
||||
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||
redraws = tui.fullRedraws;
|
||||
done(undefined);
|
||||
return new Text("", 0, 0);
|
||||
});
|
||||
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
function isAssistantMessage(message: unknown): message is AssistantMessage {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return role === "assistant";
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let agentStartMs: number | null = null;
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
agentStartMs = Date.now();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
if (agentStartMs === null) return;
|
||||
|
||||
const elapsedMs = Date.now() - agentStartMs;
|
||||
agentStartMs = null;
|
||||
if (elapsedMs <= 0) return;
|
||||
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let cacheRead = 0;
|
||||
let cacheWrite = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
for (const message of event.messages) {
|
||||
if (!isAssistantMessage(message)) continue;
|
||||
input += message.usage.input || 0;
|
||||
output += message.usage.output || 0;
|
||||
cacheRead += message.usage.cacheRead || 0;
|
||||
cacheWrite += message.usage.cacheWrite || 0;
|
||||
totalTokens += message.usage.totalTokens || 0;
|
||||
}
|
||||
|
||||
if (output <= 0) return;
|
||||
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const tokensPerSecond = output / elapsedSeconds;
|
||||
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
|
||||
ctx.ui.notify(message, "info");
|
||||
});
|
||||
}
|
||||
2
.pi/git/.gitignore
vendored
2
.pi/git/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
.pi/npm/.gitignore
vendored
2
.pi/npm/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
Audit changelog entries for all commits since the last release.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Find the last release tag:**
|
||||
```bash
|
||||
git tag --sort=-version:refname | head -1
|
||||
```
|
||||
|
||||
2. **List all commits since that tag:**
|
||||
```bash
|
||||
git log <tag>..HEAD --oneline
|
||||
```
|
||||
|
||||
3. **Read each package's [Unreleased] section:**
|
||||
- packages/ai/CHANGELOG.md
|
||||
- packages/tui/CHANGELOG.md
|
||||
- packages/coding-agent/CHANGELOG.md
|
||||
|
||||
4. **For each commit, check:**
|
||||
- Skip: changelog updates, doc-only changes, release housekeeping
|
||||
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
|
||||
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
|
||||
- Verify a changelog entry exists in the affected package(s)
|
||||
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
|
||||
|
||||
5. **Cross-package duplication rule:**
|
||||
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
|
||||
|
||||
6. **Add New Features section after changelog fixes:**
|
||||
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
|
||||
- Propose the top new features to the user for confirmation before writing them.
|
||||
- Link to relevant docs and sections whenever possible.
|
||||
|
||||
7. **Report:**
|
||||
- List commits with missing entries
|
||||
- List entries that need cross-package duplication
|
||||
- Add any missing entries directly
|
||||
|
||||
## Changelog Format Reference
|
||||
|
||||
Sections (in order):
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
|
||||
Attribution:
|
||||
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
|
||||
- External: `Added bar ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@user](https://github.com/user))`
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
For each issue:
|
||||
|
||||
1. Add the `inprogress` label to the issue via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
||||
2. Read the issue in full, including all comments and linked issues/PRs.
|
||||
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
|
||||
|
||||
4. **For bugs**:
|
||||
- Ignore any root cause analysis in the issue (likely wrong)
|
||||
- Read all related code files in full (no truncation)
|
||||
- Trace the code path and identify the actual root cause
|
||||
- Propose a fix
|
||||
|
||||
5. **For feature requests**:
|
||||
- Do not trust implementation proposals in the issue without verification
|
||||
- Read all related code files in full (no truncation)
|
||||
- Propose the most concise implementation approach
|
||||
- List affected files and changes needed
|
||||
|
||||
Do NOT implement unless explicitly asked. Analyze and propose only.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@
|
||||
|
||||
For each PR URL, do the following in order:
|
||||
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
||||
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
|
||||
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
|
||||
4. Analyze the PR diff. Read all relevant code files in full with no truncation from the current main branch and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
|
||||
5. Check for a changelog entry in the relevant `packages/*/CHANGELOG.md` files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify:
|
||||
- Entry uses correct section (`### Breaking Changes`, `### Added`, `### Fixed`, etc.)
|
||||
- External contributions include PR link and author: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/pull/123) by [@user](https://github.com/user))`
|
||||
- Breaking changes are in `### Breaking Changes`, not just `### Fixed`
|
||||
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
|
||||
7. Provide a structured review with these sections:
|
||||
- Good: solid choices or improvements
|
||||
- Bad: concrete issues, regressions, missing tests, or risks
|
||||
- Ugly: subtle or high impact problems
|
||||
8. Add Questions or Assumptions if anything is unclear.
|
||||
9. Add Change summary and Tests.
|
||||
|
||||
Output format per PR:
|
||||
PR: <url>
|
||||
Changelog:
|
||||
- ...
|
||||
Good:
|
||||
- ...
|
||||
Bad:
|
||||
- ...
|
||||
Ugly:
|
||||
- ...
|
||||
Questions or Assumptions:
|
||||
- ...
|
||||
Change summary:
|
||||
- ...
|
||||
Tests:
|
||||
- ...
|
||||
|
||||
If no issues are found, say so under Bad and Ugly.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it.
|
||||
|
||||
Additional instructions: $ARGUMENTS
|
||||
|
||||
Determine context from the conversation history first.
|
||||
|
||||
Rules for context detection:
|
||||
- If the conversation already mentions a GitHub issue or PR, use that existing context.
|
||||
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
|
||||
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
|
||||
|
||||
Unless I explicitly override something in this request, do the following in order:
|
||||
|
||||
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
|
||||
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment.
|
||||
3. Commit only files you changed in this session.
|
||||
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
|
||||
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
|
||||
6. Push the current branch.
|
||||
|
||||
Constraints:
|
||||
- Never stage unrelated files.
|
||||
- Never use `git add .` or `git add -A`.
|
||||
- Run required checks before committing if code changed.
|
||||
- Do not open a PR unless I explicitly ask.
|
||||
- If this is not GitHub issue or PR work, do not post a GitHub comment.
|
||||
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
|
||||
108
README.md
108
README.md
@@ -1,77 +1,57 @@
|
||||
<p align="center">
|
||||
<a href="https://pi.dev">
|
||||
<img alt="pi logo" src="https://pi.dev/logo-auto.svg" width="128">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/3cU7Bz4UPx"><img alt="Discord" src="https://img.shields.io/badge/discord-community-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://pi.dev">pi.dev</a> domain graciously donated by
|
||||
<br /><br />
|
||||
<a href="https://exe.dev"><img src="packages/coding-agent/docs/images/exy.png" alt="Exy mascot" width="48" /><br />exe.dev</a>
|
||||
</p>
|
||||
# sproutclaw
|
||||
|
||||
> New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
||||
|
||||
---
|
||||
## 定位
|
||||
|
||||
# Pi Agent Harness Mono Repo
|
||||
- 服务器运维助手:协助 SSH 登录、服务状态检查、日志分析、配置调整和部署排障。
|
||||
- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。
|
||||
- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。
|
||||
- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。
|
||||
- WebUI/TUI 双入口:保留控制台 TUI,同时扩展网页前端,方便在浏览器里管理会话和模型。
|
||||
|
||||
This is the home of the pi agent harness project including our self extensible coding agent.
|
||||
## 项目结构
|
||||
|
||||
* **[@earendil-works/pi-coding-agent](packages/coding-agent)**: Interactive coding agent CLI
|
||||
* **[@earendil-works/pi-agent-core](packages/agent)**: Agent runtime with tool calling and state management
|
||||
* **[@earendil-works/pi-ai](packages/ai)**: Unified multi-provider LLM API (OpenAI, Anthropic, Google, …)
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 |
|
||||
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
|
||||
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
|
||||
| `packages/tui` | 终端 UI 渲染库 |
|
||||
| `packages/web-ui` | Web UI 组件 |
|
||||
| `.pi/extensions/webui` | 本地扩展的网页对话入口 |
|
||||
|
||||
To learn more about pi:
|
||||
|
||||
* [Visit pi.dev](https://pi.dev), the project website with demos
|
||||
* [Read the documentation](https://pi.dev/docs/latest), but you can also ask the agent to explain itself
|
||||
|
||||
## Share your OSS coding agent sessions
|
||||
|
||||
If you use pi or other coding agents for open source work, please share your sessions.
|
||||
|
||||
Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks.
|
||||
|
||||
For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911).
|
||||
|
||||
To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`.
|
||||
|
||||
You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions.
|
||||
|
||||
I regularly publish my own `pi-mono` work sessions here:
|
||||
|
||||
- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono)
|
||||
|
||||
## All Packages
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[@earendil-works/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
|
||||
| **[@earendil-works/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
|
||||
| **[@earendil-works/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
|
||||
| **[@earendil-works/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
|
||||
| **[@earendil-works/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces |
|
||||
|
||||
For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat).
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents).
|
||||
|
||||
## Development
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm install # Install all dependencies
|
||||
npm run build # Build all packages
|
||||
npm run check # Lint, format, and type check
|
||||
./test.sh # Run tests (skips LLM-dependent tests without API keys)
|
||||
./pi-test.sh # Run pi from sources (can be run from any directory)
|
||||
npm install
|
||||
npm run build
|
||||
npm run check
|
||||
./test.sh
|
||||
./pi-test.sh
|
||||
```
|
||||
|
||||
> **Note:** `npm run check` requires `npm run build` to be run first. The web-ui package uses `tsc` which needs compiled `.d.ts` files from dependencies.
|
||||
运行本地源码版 Agent:
|
||||
|
||||
```bash
|
||||
./pi-test.sh
|
||||
```
|
||||
|
||||
启动 WebUI 时,在 TUI 中执行:
|
||||
|
||||
```text
|
||||
/webui on 19133
|
||||
```
|
||||
|
||||
关闭 WebUI:
|
||||
|
||||
```text
|
||||
/webui off
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
这个仓库是自用分支,默认会围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono,后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。
|
||||
|
||||
## License
|
||||
|
||||
|
||||
142
context.md
Normal file
142
context.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Code Context
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
```
|
||||
.sproutclaw/
|
||||
├── .claude/ # Claude configuration
|
||||
├── .github/ # Issue templates, workflows (issue-gate, pr-gate, approve-contributor)
|
||||
├── .husky/ # Git hooks
|
||||
├── .pi/ # pi/pi-mono agent configuration (AGENTS.md)
|
||||
├── node_modules/
|
||||
├── packages/ # 5 monorepo workspaces
|
||||
│ ├── ai/ # @earendil-works/pi-ai v0.74.0
|
||||
│ ├── agent/ # @earendil-works/pi-agent-core v0.74.0
|
||||
│ ├── coding-agent/ # @earendil-works/pi-coding-agent v0.74.0
|
||||
│ ├── tui/ # @earendil-works/pi-tui v0.74.0
|
||||
│ └── web-ui/ # @earendil-works/pi-web-ui v0.74.0
|
||||
├── scripts/ # CI/release/utility scripts
|
||||
├── tmp/ # Temp files
|
||||
├── pi-test.sh # Interactive TUI test script
|
||||
├── README.md
|
||||
├── CONTRIBUTING.md
|
||||
├── AGENTS.md
|
||||
├── biome.json # Linting/formatting config
|
||||
├── tsconfig.base.json # Shared TS config
|
||||
├── tsconfig.json # Root TS config
|
||||
├── package.json # Workspace root (npm workspaces)
|
||||
└── bun.lock / package-lock.json
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
### 1. `packages/ai` — `@earendil-works/pi-ai`
|
||||
**Unified LLM API** — automatic model discovery, provider configuration, streaming.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (`Api`, `StreamOptions`, `Model`, `KnownProvider`)
|
||||
- `src/stream.ts` — main streaming implementation
|
||||
- `src/models.ts` / `src/models.generated.ts` — model registry + auto-generated
|
||||
- `src/providers/` — individual provider implementations (Anthropic, Google, OpenAI, Mistral, etc.)
|
||||
- `src/cli.ts` — CLI for model listing etc.
|
||||
- `src/env-api-keys.ts` — credential detection from env vars
|
||||
- `src/oauth.ts` — OAuth support
|
||||
- `src/api-registry.ts` — API/stream provider registration
|
||||
- `scripts/generate-models.ts` — model list generation script
|
||||
- Subpath exports per provider (e.g. `./anthropic`, `./google`, etc.)
|
||||
|
||||
### 2. `packages/agent` — `@earendil-works/pi-agent-core`
|
||||
**General-purpose agent** — transport abstraction, state management, attachment support.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (agent state, attachments, transports)
|
||||
- `src/agent.ts` — agent implementation
|
||||
- `src/agent-loop.ts` — agent main loop
|
||||
- `src/proxy.ts` — proxy support
|
||||
- `src/harness/` — test harness utilities
|
||||
|
||||
### 3. `packages/coding-agent` — `@earendil-works/pi-coding-agent`
|
||||
**Coding agent CLI** — the `pi` CLI binary with read, bash, edit, write tools and session management.
|
||||
|
||||
- `src/cli.ts` / `src/cli/` — CLI argument parsing / entry point
|
||||
- `src/main.ts` — main agent runner
|
||||
- `src/index.ts` — package entry
|
||||
- `src/config.ts` — configuration management
|
||||
- `src/core/` — core logic (model resolution, hooks, tool execution, session management)
|
||||
- `src/modes/` — operational modes
|
||||
- `src/bun/` — Bun-specific support
|
||||
- `src/migrations.ts` — data migration utilities
|
||||
- `src/package-manager-cli.ts` — package manager interaction
|
||||
- `docs/` — provider setup docs
|
||||
- `examples/` — extension examples (custom provider, sandbox)
|
||||
- Binary name: `pi`
|
||||
|
||||
### 4. `packages/tui` — `@earendil-works/pi-tui`
|
||||
**Terminal UI library** — differential rendering for text-based terminal applications.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/tui.ts` — core TUI rendering engine
|
||||
- `src/terminal.ts` — low-level terminal I/O
|
||||
- `src/keys.ts` / `src/keybindings.ts` — key input handling
|
||||
- `src/components/` — reusable UI components
|
||||
- `src/editor-component.ts` — text editor component
|
||||
- `src/autocomplete.ts` — autocomplete support
|
||||
- `src/fuzzy.ts` — fuzzy matching
|
||||
- `src/terminal-image.ts` — image rendering in terminal
|
||||
- `src/kill-ring.ts` / `src/undo-stack.ts` — editing helpers
|
||||
- `src/stdin-buffer.ts` / `src/utils.ts` — utilities
|
||||
|
||||
### 5. `packages/web-ui` — `@earendil-works/pi-web-ui`
|
||||
**Reusable web UI components** — AI chat interfaces powered by pi-ai.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/app.css` — Tailwind CSS stylesheet
|
||||
- `src/ChatPanel.ts` — main chat panel component
|
||||
- `src/components/` — reusable React components
|
||||
- `src/dialogs/` — dialog components
|
||||
- `src/prompts/` — prompt templates/management
|
||||
- `src/storage/` — client-side storage abstraction
|
||||
- `src/tools/` — tool integration
|
||||
- `src/utils/` — utilities
|
||||
- Build: TypeScript + Tailwind CSS
|
||||
|
||||
## Root Scripts (`scripts/`)
|
||||
|
||||
- `release.mjs` — automated release workflow (version + changelog + publish)
|
||||
- `sync-versions.js` — lockstep version sync across all packages
|
||||
- `cost.ts` / `stats.ts` — analytics/telemetry stats
|
||||
- `tool-stats.ts` / `edit-tool-stats.mjs` / `read-tool-stats.mjs` — tool usage statistics
|
||||
- `profile-coding-agent-node.mjs` — Node.js profiling for coding agent
|
||||
- `build-binaries.sh` — binary build script
|
||||
- `browser-smoke-entry.ts` / `check-browser-smoke.mjs` — browser smoke tests
|
||||
- `session-transcripts.ts` / `session-context-stats.mjs` — session logging
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
pi-tui (terminal UI) ─── pi-coding-agent (CLI + tools) ─── pi-agent-core (agent loop)
|
||||
│
|
||||
pi-web-ui (web UI) ────────────┤
|
||||
│
|
||||
pi-ai (LLM provider abstraction)
|
||||
```
|
||||
|
||||
- **pi-ai** is the foundation layer providing unified LLM API access (Anthropic, Google, OpenAI, Azure, Mistral, Bedrock, etc.)
|
||||
- **pi-agent-core** builds on pi-ai with agent loop, transport abstraction, and state management
|
||||
- **pi-coding-agent** is the main product: a coding agent CLI (`pi` command) with read/bash/edit/write tools and session management
|
||||
- **pi-tui** is a standalone TUI rendering library used by pi-coding-agent
|
||||
- **pi-web-ui** provides reusable chat UI components (React) powered by pi-ai
|
||||
|
||||
## Key Facts
|
||||
|
||||
- **Lockstep versioning**: all packages share the same version (currently 0.74.0)
|
||||
- **Release automation**: `npm run release:patch` / `release:minor` via `scripts/release.mjs`
|
||||
- **TypeScript with tsgo**: uses `tsgo` (TypeScript native preview) for fast builds; web-ui uses `tsc`
|
||||
- **Linting**: Biome (v2.3.5) for formatting and linting (`npm run check`)
|
||||
- **Testing**: vitest for ai/agent/coding-agent; node --test for tui
|
||||
- **No `any` types allowed**, standard top-level imports only (no inline `import()`)
|
||||
- **GitHub CI**: issue-gate, pr-gate, approve-contributor workflows for contributor management
|
||||
|
||||
## Start Here
|
||||
|
||||
Open `package.json` (root) for workspace structure and available scripts. For a specific package, start with its `package.json` then `src/index.ts` for the public API surface.
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -5515,6 +5515,7 @@
|
||||
"node_modules/lit": {
|
||||
"version": "3.3.2",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
"lit-element": "^4.2.0",
|
||||
@@ -6639,6 +6640,7 @@
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.5.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
@@ -6663,7 +6665,8 @@
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.4",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
@@ -6761,6 +6764,7 @@
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6839,6 +6843,7 @@
|
||||
"version": "4.21.0",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -6926,6 +6931,7 @@
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.3",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -7034,6 +7040,7 @@
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7281,6 +7288,7 @@
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
204
packages/agent/docs/agent-harness.md
Normal file
204
packages/agent/docs/agent-harness.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# AgentHarness lifecycle
|
||||
|
||||
`AgentHarness` is the orchestration layer above the low-level `Agent`. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
|
||||
|
||||
This document describes the current direction and implemented behavior. Some extension/session-facade details are planned and called out explicitly.
|
||||
|
||||
## Ultimate lifecycle goal
|
||||
|
||||
Harness listeners and hooks should be able to close over the `AgentHarness` instance and call public harness APIs from any event where those APIs are documented as allowed. Those calls must not corrupt in-flight turn snapshots, reorder persisted transcript entries, lose pending writes, deadlock settlement, or leave the harness in the wrong phase.
|
||||
|
||||
The intended rule is:
|
||||
|
||||
- structural operations remain rejected while busy
|
||||
- queue operations are accepted at documented turn-safe points
|
||||
- runtime config setters update future snapshots without mutating the current provider request
|
||||
- session writes made while busy are durably queued and flushed in deterministic order
|
||||
- getters return latest harness config, not in-flight snapshots
|
||||
|
||||
A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite.
|
||||
|
||||
## State model
|
||||
|
||||
The harness separates state into four categories.
|
||||
|
||||
### Harness config
|
||||
|
||||
Harness config is the latest runtime configuration set by the application or extensions:
|
||||
|
||||
- model
|
||||
- thinking level
|
||||
- tools
|
||||
- active tool names
|
||||
- resources
|
||||
- system prompt or system prompt provider
|
||||
|
||||
Getters return harness config. They do not return the snapshot used by an in-flight provider request.
|
||||
|
||||
Setters update harness config immediately, including while a turn is in flight. Changes affect the next turn snapshot, not the currently running provider request.
|
||||
|
||||
`setResources()` accepts concrete resources and emits `resources_update` on every call with shallow-copied current and previous resources. Applications own loading/reloading resources from disk or other sources and should call `setResources()` with new values.
|
||||
|
||||
`getResources()` returns shallow-copied current resources. It is a live config read, not the last turn snapshot.
|
||||
|
||||
### Turn snapshot
|
||||
|
||||
A turn snapshot is the concrete state used for one LLM turn. It is created by `createTurnState()` and contains:
|
||||
|
||||
- persisted session messages
|
||||
- resolved resources
|
||||
- resolved system prompt
|
||||
- model
|
||||
- thinking level
|
||||
- all tools
|
||||
- active tools
|
||||
|
||||
Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot.
|
||||
|
||||
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
|
||||
|
||||
### Session
|
||||
|
||||
The session contains persisted entries only. Session reads return persisted state and do not include queued writes.
|
||||
|
||||
### Pending session writes
|
||||
|
||||
Session writes requested while an operation is active are queued as pending session writes. Pending writes are based on session-entry shapes without generated fields (`id`, `parentId`, `timestamp`).
|
||||
|
||||
Pending session writes are always persisted. They are flushed at save points, at operation settlement, and in failure cleanup.
|
||||
|
||||
A public pending-writes/session-facade API is planned but not implemented yet.
|
||||
|
||||
## Operation phases
|
||||
|
||||
The harness has an explicit phase:
|
||||
|
||||
```ts
|
||||
type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry";
|
||||
```
|
||||
|
||||
Structural operations require `phase === "idle"` and synchronously set the phase before the first `await`:
|
||||
|
||||
- `prompt`
|
||||
- `skill`
|
||||
- `promptFromTemplate`
|
||||
- `compact`
|
||||
- `navigateTree`
|
||||
|
||||
Starting another structural operation while the harness is not idle throws.
|
||||
|
||||
The following operations are allowed during a turn where appropriate:
|
||||
|
||||
- `steer`
|
||||
- `followUp`
|
||||
- `nextTurn`
|
||||
- `abort`
|
||||
- runtime config setters
|
||||
|
||||
Phase/settlement semantics are still provisional and need a full lifecycle pass.
|
||||
|
||||
## Turn execution
|
||||
|
||||
`prompt`, `skill`, and `promptFromTemplate` follow the same flow:
|
||||
|
||||
1. Assert idle and set phase to `"turn"`.
|
||||
2. Create a turn snapshot with `createTurnState()`.
|
||||
3. Derive invocation text from that snapshot.
|
||||
4. Execute the turn with `executeTurn()`.
|
||||
|
||||
`skill` and `promptFromTemplate` resolve their resource from the same snapshot that is passed to the turn. They do not resolve resources separately.
|
||||
|
||||
`steer`, `followUp`, and `nextTurn` accept text plus optional images and create user messages internally. `nextTurn` messages are inserted before the new user message on the next user-initiated turn.
|
||||
|
||||
Queue modes are live, not turn-snapshotted:
|
||||
|
||||
- `steeringMode`
|
||||
- `followUpMode`
|
||||
|
||||
Changing a queue mode during a run affects the next queue drain. Queue drains happen at safe points.
|
||||
|
||||
## Save points
|
||||
|
||||
A save point occurs after an assistant turn and its tool-result messages have completed.
|
||||
|
||||
At a save point the harness:
|
||||
|
||||
1. flushes pending session writes after the agent-emitted messages for that turn
|
||||
2. creates a fresh turn snapshot if the low-level loop may continue
|
||||
3. applies the fresh context/model/thinking-level state before the next provider request
|
||||
|
||||
This lets model, thinking level, tool, resource, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. The loop callbacks are not recreated at save points.
|
||||
|
||||
The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at the provider boundary:
|
||||
|
||||
- `"off"` -> `undefined`
|
||||
- all other thinking levels pass through
|
||||
|
||||
No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review.
|
||||
|
||||
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation throws and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
|
||||
|
||||
## Hooks and events
|
||||
|
||||
Current hooks receive only the event payload. There is no extension context object yet.
|
||||
|
||||
Event payloads describe what is happening. Harness getters describe latest config for future snapshots.
|
||||
|
||||
The split between harness-specific events (`AgentHarnessOwnEvent`) and the union of low-level plus harness events (`AgentHarnessEvent`) is provisional but useful for distinguishing hookable harness events from public subscription events.
|
||||
|
||||
A future extension context may expose the harness and a queued-write session facade.
|
||||
|
||||
## Planned session facade
|
||||
|
||||
Extensions should eventually interact with a harness-scoped session facade rather than the raw session.
|
||||
|
||||
Planned read semantics:
|
||||
|
||||
- reads delegate to persisted session state
|
||||
- reads do not include queued pending writes
|
||||
|
||||
Planned write semantics:
|
||||
|
||||
- idle: persist immediately
|
||||
- busy: enqueue as pending session writes
|
||||
|
||||
A planned diagnostics API may expose pending writes explicitly:
|
||||
|
||||
```ts
|
||||
getPendingWrites(): readonly PendingSessionWrite[]
|
||||
```
|
||||
|
||||
Agent-emitted messages are persisted on `message_end` to preserve transcript ordering. Pending extension/session writes flush after those messages at save points.
|
||||
|
||||
## Abort
|
||||
|
||||
Abort is allowed during a turn. It aborts the low-level run and clears low-level steering/follow-up queues.
|
||||
|
||||
Abort does not discard pending session writes. Pending writes flush at the next save point if reached, at `agent_end`, or in operation failure cleanup.
|
||||
|
||||
Abort barrier semantics still need an audit.
|
||||
|
||||
## Compaction and tree navigation
|
||||
|
||||
Compaction and tree navigation are structural session mutations.
|
||||
|
||||
They are allowed only while idle and are not queued. They operate on persisted session state. The next prompt creates a fresh turn snapshot.
|
||||
|
||||
Branch summary generation is part of the tree navigation operation.
|
||||
|
||||
Auto-compaction and retry decision points are not implemented in `AgentHarness` yet.
|
||||
|
||||
## Final lifecycle hardening todo
|
||||
|
||||
Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event:
|
||||
|
||||
- runtime config setters from low-level lifecycle events and harness events
|
||||
- resource/tool/model/thinking updates during active turns and save points
|
||||
- session writes from listeners and hooks, including writes from `settled`
|
||||
- queue operations from turn events, tool events, and provider hooks
|
||||
- rejected structural operations while busy
|
||||
- abort from listeners/hooks
|
||||
- getter behavior during active operations
|
||||
- deterministic ordering of agent-emitted messages and pending listener writes
|
||||
- no deadlocks when async listeners call harness APIs and await them
|
||||
- phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation
|
||||
@@ -153,13 +153,15 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
|
||||
* Main loop logic shared by agentLoop and agentLoopContinue.
|
||||
*/
|
||||
async function runLoop(
|
||||
currentContext: AgentContext,
|
||||
initialContext: AgentContext,
|
||||
newMessages: AgentMessage[],
|
||||
config: AgentLoopConfig,
|
||||
initialConfig: AgentLoopConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
emit: AgentEventSink,
|
||||
streamFn?: StreamFn,
|
||||
): Promise<void> {
|
||||
let currentContext = initialContext;
|
||||
let config = initialConfig;
|
||||
let firstTurn = true;
|
||||
// Check for steering messages at start (user may have typed while waiting)
|
||||
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
|
||||
@@ -215,6 +217,27 @@ async function runLoop(
|
||||
|
||||
await emit({ type: "turn_end", message, toolResults });
|
||||
|
||||
const nextTurnContext = {
|
||||
message,
|
||||
toolResults,
|
||||
context: currentContext,
|
||||
newMessages,
|
||||
};
|
||||
const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
|
||||
if (nextTurnSnapshot) {
|
||||
currentContext = nextTurnSnapshot.context ?? currentContext;
|
||||
config = {
|
||||
...config,
|
||||
model: nextTurnSnapshot.model ?? config.model,
|
||||
reasoning:
|
||||
nextTurnSnapshot.thinkingLevel === undefined
|
||||
? config.reasoning
|
||||
: nextTurnSnapshot.thinkingLevel === "off"
|
||||
? undefined
|
||||
: nextTurnSnapshot.thinkingLevel,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
await config.shouldStopAfterTurn?.({
|
||||
message,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentLoopConfig,
|
||||
AgentLoopTurnUpdate,
|
||||
AgentMessage,
|
||||
AgentState,
|
||||
AgentTool,
|
||||
@@ -52,7 +53,7 @@ const DEFAULT_MODEL = {
|
||||
maxTokens: 0,
|
||||
} satisfies Model<any>;
|
||||
|
||||
type QueueMode = "all" | "one-at-a-time";
|
||||
export type QueueMode = "all" | "one-at-a-time";
|
||||
|
||||
type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"> & {
|
||||
isStreaming: boolean;
|
||||
@@ -101,6 +102,9 @@ export interface AgentOptions {
|
||||
onResponse?: SimpleStreamOptions["onResponse"];
|
||||
beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
|
||||
afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
|
||||
prepareNextTurn?: (
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
|
||||
steeringMode?: QueueMode;
|
||||
followUpMode?: QueueMode;
|
||||
sessionId?: string;
|
||||
@@ -175,6 +179,9 @@ export class Agent {
|
||||
context: AfterToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AfterToolCallResult | undefined>;
|
||||
public prepareNextTurn?: (
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
|
||||
private activeRun?: ActiveRun;
|
||||
/** Session identifier forwarded to providers for cache-aware backends. */
|
||||
public sessionId?: string;
|
||||
@@ -197,6 +204,7 @@ export class Agent {
|
||||
this.onResponse = options.onResponse;
|
||||
this.beforeToolCall = options.beforeToolCall;
|
||||
this.afterToolCall = options.afterToolCall;
|
||||
this.prepareNextTurn = options.prepareNextTurn;
|
||||
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
|
||||
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
|
||||
this.sessionId = options.sessionId;
|
||||
@@ -421,6 +429,7 @@ export class Agent {
|
||||
toolExecution: this.toolExecution,
|
||||
beforeToolCall: this.beforeToolCall,
|
||||
afterToolCall: this.afterToolCall,
|
||||
prepareNextTurn: this.prepareNextTurn ? async () => await this.prepareNextTurn?.(this.signal) : undefined,
|
||||
convertToLlm: this.convertToLlm,
|
||||
transformContext: this.transformContext,
|
||||
getApiKey: this.getApiKey,
|
||||
|
||||
@@ -1,193 +1,160 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AssistantMessage, ImageContent, Model } from "@earendil-works/pi-ai";
|
||||
import { Agent } from "../agent.js";
|
||||
import type { AssistantMessage, ImageContent, Model, UserMessage } from "@earendil-works/pi-ai";
|
||||
import { Agent, type QueueMode } from "../agent.js";
|
||||
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../types.js";
|
||||
import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.js";
|
||||
import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction.js";
|
||||
import { expandPromptTemplate } from "./prompt-templates.js";
|
||||
import { expandSkillCommand } from "./skills.js";
|
||||
import { formatPromptTemplateInvocation } from "./prompt-templates.js";
|
||||
import { formatSkillInvocation } from "./skills.js";
|
||||
import type {
|
||||
AbortResult,
|
||||
AgentHarnessContext,
|
||||
AgentHarnessConversationState,
|
||||
AgentHarnessEvent,
|
||||
AgentHarnessEventResultMap,
|
||||
AgentHarnessOperationState,
|
||||
AgentHarnessOptions,
|
||||
AgentHarnessOwnEvent,
|
||||
AgentHarnessPhase,
|
||||
AgentHarnessResources,
|
||||
AgentHarnessTurnState,
|
||||
ExecutionEnv,
|
||||
NavigateTreeResult,
|
||||
PendingSessionWrite,
|
||||
PromptTemplate,
|
||||
Session,
|
||||
Skill,
|
||||
} from "./types.js";
|
||||
|
||||
function createUserMessage(text: string, images?: ImageContent[]): AgentMessage {
|
||||
function createUserMessage(text: string, images?: ImageContent[]): UserMessage {
|
||||
const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }];
|
||||
if (images) content.push(...images);
|
||||
return { role: "user", content, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
export class AgentHarness {
|
||||
export class AgentHarness<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentTool = AgentTool,
|
||||
> {
|
||||
readonly agent: Agent;
|
||||
readonly env: ExecutionEnv;
|
||||
readonly conversation: AgentHarnessConversationState;
|
||||
readonly operation: AgentHarnessOperationState;
|
||||
|
||||
private session: Session;
|
||||
private resourcesInput?: AgentHarnessOptions["resources"];
|
||||
private systemPrompt: AgentHarnessOptions["systemPrompt"];
|
||||
private requestAuth?: AgentHarnessOptions["requestAuth"];
|
||||
private toolRegistry = new Map<string, AgentTool>();
|
||||
private listeners = new Set<(event: AgentHarnessEvent, signal?: AbortSignal) => Promise<void> | void>();
|
||||
private hooks = new Map<
|
||||
keyof AgentHarnessEventResultMap,
|
||||
Set<(event: any, ctx: AgentHarnessContext) => Promise<any> | any>
|
||||
private model: Model<any>;
|
||||
private thinkingLevel: ThinkingLevel;
|
||||
private activeToolNames: string[];
|
||||
private nextTurnQueue: AgentMessage[] = [];
|
||||
private phase: AgentHarnessPhase = "idle";
|
||||
private steerQueue: UserMessage[] = [];
|
||||
private followUpQueue: UserMessage[] = [];
|
||||
private pendingSessionWrites: PendingSessionWrite[] = [];
|
||||
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
private systemPrompt: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>["systemPrompt"];
|
||||
private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"];
|
||||
private tools = new Map<string, TTool>();
|
||||
private listeners = new Set<
|
||||
(event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal) => Promise<void> | void
|
||||
>();
|
||||
private hooks = new Map<keyof AgentHarnessEventResultMap, Set<(event: any) => Promise<any> | any>>();
|
||||
|
||||
constructor(options: AgentHarnessOptions) {
|
||||
constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>) {
|
||||
this.agent = new Agent({
|
||||
initialState: {
|
||||
model: options.model,
|
||||
thinkingLevel: options.thinkingLevel,
|
||||
tools: options.tools ?? [],
|
||||
},
|
||||
steeringMode: options.steeringMode,
|
||||
followUpMode: options.followUpMode,
|
||||
});
|
||||
this.env = options.env;
|
||||
this.session = options.session;
|
||||
this.resourcesInput = options.resources;
|
||||
this.resources = options.resources ?? {};
|
||||
this.systemPrompt = options.systemPrompt;
|
||||
this.requestAuth = options.requestAuth;
|
||||
for (const tool of this.agent.state.tools) {
|
||||
this.toolRegistry.set(tool.name, tool);
|
||||
this.getApiKeyAndHeaders = options.getApiKeyAndHeaders;
|
||||
for (const tool of options.tools ?? []) {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
this.conversation = {
|
||||
session: options.session,
|
||||
model: options.model,
|
||||
thinkingLevel: options.thinkingLevel ?? this.agent.state.thinkingLevel,
|
||||
activeToolNames: options.activeToolNames ?? this.agent.state.tools.map((tool) => tool.name),
|
||||
nextTurnQueue: [],
|
||||
};
|
||||
this.agent.state.model = this.conversation.model;
|
||||
this.agent.state.thinkingLevel = this.conversation.thinkingLevel;
|
||||
this.model = options.model;
|
||||
this.thinkingLevel = options.thinkingLevel ?? this.agent.state.thinkingLevel;
|
||||
this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name);
|
||||
this.agent.state.model = this.model;
|
||||
this.agent.state.thinkingLevel = this.thinkingLevel;
|
||||
this.agent.getApiKey = async (provider) => {
|
||||
const model = this.conversation.model;
|
||||
if (!this.requestAuth || model.provider !== provider) return undefined;
|
||||
return (await this.requestAuth(model))?.apiKey;
|
||||
const model = this.model;
|
||||
if (!this.getApiKeyAndHeaders || model.provider !== provider) return undefined;
|
||||
return (await this.getApiKeyAndHeaders(model))?.apiKey;
|
||||
};
|
||||
this.operation = {
|
||||
idle: true,
|
||||
abortRequested: false,
|
||||
steerQueue: [],
|
||||
followUpQueue: [],
|
||||
pendingMutations: {
|
||||
appendMessages: [],
|
||||
},
|
||||
};
|
||||
this.agent.transformContext = async (messages, signal) => {
|
||||
const result = await this.emitHook("context", { type: "context", messages: [...messages] }, signal);
|
||||
this.agent.transformContext = async (messages) => {
|
||||
const result = await this.emitHook({ type: "context", messages: [...messages] });
|
||||
return result?.messages ?? messages;
|
||||
};
|
||||
this.agent.beforeToolCall = async ({ toolCall, args }, signal) => {
|
||||
const result = await this.emitHook(
|
||||
"tool_call",
|
||||
{
|
||||
type: "tool_call",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
input: args as Record<string, unknown>,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
this.agent.beforeToolCall = async ({ toolCall, args }) => {
|
||||
const result = await this.emitHook({
|
||||
type: "tool_call",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
input: args as Record<string, unknown>,
|
||||
});
|
||||
return result ? { block: result.block, reason: result.reason } : undefined;
|
||||
};
|
||||
this.agent.afterToolCall = async ({ toolCall, args, result, isError }, signal) => {
|
||||
const patch = await this.emitHook(
|
||||
"tool_result",
|
||||
{
|
||||
type: "tool_result",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
input: args as Record<string, unknown>,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
|
||||
const patch = await this.emitHook({
|
||||
type: "tool_result",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
input: args as Record<string, unknown>,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError,
|
||||
});
|
||||
return patch
|
||||
? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate }
|
||||
: undefined;
|
||||
};
|
||||
this.agent.onPayload = async (payload) => {
|
||||
const result = await this.emitHook("before_provider_request", { type: "before_provider_request", payload });
|
||||
const result = await this.emitHook({ type: "before_provider_request", payload });
|
||||
return result?.payload ?? payload;
|
||||
};
|
||||
this.agent.onResponse = async (response) => {
|
||||
const headers = { ...(response.headers as Record<string, string>) };
|
||||
await this.emitOwn({ type: "after_provider_response", status: response.status, headers }, this.agent.signal);
|
||||
};
|
||||
this.agent.prepareNextTurn = async () => {
|
||||
await this.flushPendingSessionWrites();
|
||||
const turnState = await this.createTurnState();
|
||||
this.applyTurnState(turnState);
|
||||
return {
|
||||
context: {
|
||||
systemPrompt: turnState.systemPrompt,
|
||||
messages: turnState.messages.slice(),
|
||||
tools: turnState.activeTools.slice(),
|
||||
},
|
||||
model: turnState.model,
|
||||
thinkingLevel: turnState.thinkingLevel,
|
||||
};
|
||||
};
|
||||
this.agent.subscribe(async (event, signal) => {
|
||||
await this.handleAgentEvent(event, signal);
|
||||
});
|
||||
void this.syncFromTree();
|
||||
}
|
||||
|
||||
private createContext(signal?: AbortSignal): AgentHarnessContext {
|
||||
return {
|
||||
env: this.env,
|
||||
conversation: this.conversation,
|
||||
operation: this.operation,
|
||||
abortSignal: signal,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveResources(signal?: AbortSignal): Promise<AgentHarnessResources> {
|
||||
return typeof this.resourcesInput === "function"
|
||||
? await this.resourcesInput(this.createContext(signal))
|
||||
: (this.resourcesInput ?? {});
|
||||
}
|
||||
|
||||
private getActiveTools(): AgentTool[] {
|
||||
return this.conversation.activeToolNames
|
||||
.map((name) => this.toolRegistry.get(name))
|
||||
.filter((tool): tool is AgentTool => tool !== undefined);
|
||||
}
|
||||
|
||||
private async resolveSystemPrompt(resources: AgentHarnessResources): Promise<string> {
|
||||
if (!this.systemPrompt) return "You are a helpful assistant.";
|
||||
if (typeof this.systemPrompt === "string") return this.systemPrompt;
|
||||
return await this.systemPrompt({
|
||||
env: this.env,
|
||||
session: this.session,
|
||||
model: this.conversation.model,
|
||||
thinkingLevel: this.conversation.thinkingLevel,
|
||||
activeTools: this.getActiveTools(),
|
||||
resources,
|
||||
});
|
||||
}
|
||||
|
||||
private async emitOwn(event: AgentHarnessOwnEvent, signal?: AbortSignal): Promise<void> {
|
||||
private async emitOwn(event: AgentHarnessOwnEvent<TSkill, TPromptTemplate>, signal?: AbortSignal): Promise<void> {
|
||||
for (const listener of this.listeners) {
|
||||
await listener(event, signal);
|
||||
}
|
||||
}
|
||||
|
||||
private async emitAny(event: AgentHarnessEvent, signal?: AbortSignal): Promise<void> {
|
||||
private async emitAny(event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal): Promise<void> {
|
||||
for (const listener of this.listeners) {
|
||||
await listener(event, signal);
|
||||
}
|
||||
}
|
||||
|
||||
private async emitHook<TType extends keyof AgentHarnessEventResultMap>(
|
||||
type: TType,
|
||||
event: Extract<AgentHarnessOwnEvent, { type: TType }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentHarnessEventResultMap[TType] | undefined> {
|
||||
const handlers = this.hooks.get(type);
|
||||
const handlers = this.hooks.get(event.type as TType);
|
||||
if (!handlers || handlers.size === 0) return undefined;
|
||||
let lastResult: AgentHarnessEventResultMap[TType] | undefined;
|
||||
for (const handler of handlers) {
|
||||
const result = await handler(event, this.createContext(signal));
|
||||
const result = await handler(event);
|
||||
if (result !== undefined) {
|
||||
lastResult = result;
|
||||
}
|
||||
@@ -198,69 +165,89 @@ export class AgentHarness {
|
||||
private async emitQueueUpdate(): Promise<void> {
|
||||
await this.emitOwn({
|
||||
type: "queue_update",
|
||||
steer: [...this.operation.steerQueue],
|
||||
followUp: [...this.operation.followUpQueue],
|
||||
nextTurn: [...this.conversation.nextTurnQueue],
|
||||
steer: [...this.steerQueue],
|
||||
followUp: [...this.followUpQueue],
|
||||
nextTurn: [...this.nextTurnQueue],
|
||||
});
|
||||
}
|
||||
|
||||
private async syncFromTree(): Promise<void> {
|
||||
private async createTurnState(): Promise<AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>> {
|
||||
const context = await this.session.buildContext();
|
||||
this.agent.state.messages = context.messages;
|
||||
if (context.model && this.conversation.model) {
|
||||
// leave active model untouched; harness-level model is source of truth
|
||||
const resources = this.getResources();
|
||||
const tools = [...this.tools.values()];
|
||||
const activeTools = this.activeToolNames
|
||||
.map((name) => this.tools.get(name))
|
||||
.filter((tool): tool is TTool => tool !== undefined);
|
||||
let systemPrompt = "You are a helpful assistant.";
|
||||
if (typeof this.systemPrompt === "string") {
|
||||
systemPrompt = this.systemPrompt;
|
||||
} else if (this.systemPrompt) {
|
||||
systemPrompt = await this.systemPrompt({
|
||||
env: this.env,
|
||||
session: this.session,
|
||||
model: this.model,
|
||||
thinkingLevel: this.thinkingLevel,
|
||||
activeTools,
|
||||
resources,
|
||||
});
|
||||
}
|
||||
this.agent.state.systemPrompt = await this.resolveSystemPrompt(await this.resolveResources());
|
||||
return {
|
||||
messages: context.messages,
|
||||
resources,
|
||||
systemPrompt,
|
||||
model: this.model,
|
||||
thinkingLevel: this.thinkingLevel,
|
||||
tools,
|
||||
activeTools,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyPendingMutations(): Promise<void> {
|
||||
for (const message of this.operation.pendingMutations.appendMessages) {
|
||||
await this.session.appendMessage(message);
|
||||
}
|
||||
this.operation.pendingMutations.appendMessages = [];
|
||||
private applyTurnState(turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>): void {
|
||||
this.agent.state.messages = turnState.messages;
|
||||
this.agent.state.systemPrompt = turnState.systemPrompt;
|
||||
this.agent.state.model = turnState.model;
|
||||
this.agent.state.thinkingLevel = turnState.thinkingLevel;
|
||||
this.agent.state.tools = turnState.activeTools;
|
||||
}
|
||||
|
||||
if (this.operation.pendingMutations.model) {
|
||||
const model = this.operation.pendingMutations.model;
|
||||
const previousModel = this.conversation.model;
|
||||
this.conversation.model = model;
|
||||
this.agent.state.model = model;
|
||||
await this.session.appendModelChange(model.provider, model.id);
|
||||
await this.emitOwn({ type: "model_select", model, previousModel, source: "set" });
|
||||
this.operation.pendingMutations.model = undefined;
|
||||
}
|
||||
private validateToolNames(toolNames: string[]): void {
|
||||
const missing = toolNames.filter((name) => !this.tools.has(name));
|
||||
if (missing.length > 0) throw new Error(`Unknown tool(s): ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
if (this.operation.pendingMutations.thinkingLevel !== undefined) {
|
||||
const level = this.operation.pendingMutations.thinkingLevel;
|
||||
const previousLevel = this.conversation.thinkingLevel;
|
||||
this.conversation.thinkingLevel = level;
|
||||
this.agent.state.thinkingLevel = level;
|
||||
await this.session.appendThinkingLevelChange(level);
|
||||
await this.emitOwn({ type: "thinking_level_select", level, previousLevel });
|
||||
this.operation.pendingMutations.thinkingLevel = undefined;
|
||||
private async flushPendingSessionWrites(): Promise<void> {
|
||||
const writes = this.pendingSessionWrites;
|
||||
this.pendingSessionWrites = [];
|
||||
for (const write of writes) {
|
||||
if (write.type === "message") {
|
||||
await this.session.appendMessage(write.message);
|
||||
} else if (write.type === "model_change") {
|
||||
await this.session.appendModelChange(write.provider, write.modelId);
|
||||
} else if (write.type === "thinking_level_change") {
|
||||
await this.session.appendThinkingLevelChange(write.thinkingLevel);
|
||||
} else if (write.type === "custom") {
|
||||
await this.session.appendCustomEntry(write.customType, write.data);
|
||||
} else if (write.type === "custom_message") {
|
||||
await this.session.appendCustomMessageEntry(write.customType, write.content, write.display, write.details);
|
||||
} else if (write.type === "label") {
|
||||
await this.session.appendLabel(write.targetId, write.label);
|
||||
} else if (write.type === "session_info") {
|
||||
await this.session.appendSessionName(write.name ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.operation.pendingMutations.activeToolNames) {
|
||||
this.conversation.activeToolNames = [...this.operation.pendingMutations.activeToolNames];
|
||||
this.agent.state.tools = this.conversation.activeToolNames
|
||||
.map((name) => this.toolRegistry.get(name))
|
||||
.filter((tool): tool is (typeof this.agent.state.tools)[number] => tool !== undefined);
|
||||
this.operation.pendingMutations.activeToolNames = undefined;
|
||||
}
|
||||
|
||||
await this.syncFromTree();
|
||||
}
|
||||
|
||||
private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise<void> {
|
||||
await this.emitAny(event, signal);
|
||||
if (event.type === "message_start") {
|
||||
const steerIndex = this.operation.steerQueue.indexOf(event.message);
|
||||
if (event.type === "message_start" && event.message.role === "user") {
|
||||
const steerIndex = this.steerQueue.indexOf(event.message);
|
||||
if (steerIndex !== -1) {
|
||||
this.operation.steerQueue.splice(steerIndex, 1);
|
||||
this.steerQueue.splice(steerIndex, 1);
|
||||
await this.emitQueueUpdate();
|
||||
} else {
|
||||
const followUpIndex = this.operation.followUpQueue.indexOf(event.message);
|
||||
const followUpIndex = this.followUpQueue.indexOf(event.message);
|
||||
if (followUpIndex !== -1) {
|
||||
this.operation.followUpQueue.splice(followUpIndex, 1);
|
||||
this.followUpQueue.splice(followUpIndex, 1);
|
||||
await this.emitQueueUpdate();
|
||||
}
|
||||
}
|
||||
@@ -269,55 +256,47 @@ export class AgentHarness {
|
||||
await this.session.appendMessage(event.message);
|
||||
}
|
||||
if (event.type === "turn_end") {
|
||||
const hadPendingMutations =
|
||||
this.operation.pendingMutations.appendMessages.length > 0 ||
|
||||
this.operation.pendingMutations.model !== undefined ||
|
||||
this.operation.pendingMutations.thinkingLevel !== undefined ||
|
||||
this.operation.pendingMutations.activeToolNames !== undefined;
|
||||
await this.emitOwn(
|
||||
{ type: "save_point", liveOperationId: this.operation.liveOperationId ?? "unknown", hadPendingMutations },
|
||||
signal,
|
||||
);
|
||||
if (hadPendingMutations) {
|
||||
await this.applyPendingMutations();
|
||||
}
|
||||
const hadPendingMutations = this.pendingSessionWrites.length > 0;
|
||||
await this.flushPendingSessionWrites();
|
||||
await this.emitOwn({
|
||||
type: "save_point",
|
||||
hadPendingMutations,
|
||||
});
|
||||
}
|
||||
if (event.type === "agent_end") {
|
||||
this.operation.idle = true;
|
||||
this.operation.liveOperationId = undefined;
|
||||
this.operation.abortRequested = false;
|
||||
await this.syncFromTree();
|
||||
await this.emitOwn({ type: "settled", nextTurnCount: this.conversation.nextTurnQueue.length }, signal);
|
||||
await this.flushPendingSessionWrites();
|
||||
this.phase = "idle";
|
||||
await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal);
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(text: string, options?: { images?: ImageContent[] }): Promise<AssistantMessage> {
|
||||
if (!this.operation.idle) throw new Error("AgentHarness is busy");
|
||||
private async executeTurn(
|
||||
turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
|
||||
text: string,
|
||||
options?: { images?: ImageContent[] },
|
||||
): Promise<AssistantMessage> {
|
||||
this.applyTurnState(turnState);
|
||||
const beforeLength = this.agent.state.messages.length;
|
||||
this.operation.idle = false;
|
||||
this.operation.liveOperationId = randomUUID();
|
||||
const resources = await this.resolveResources(this.agent.signal);
|
||||
let messages: AgentMessage[] = [createUserMessage(text, options?.images)];
|
||||
if (this.conversation.nextTurnQueue.length > 0) {
|
||||
messages = [messages[0]!, ...this.conversation.nextTurnQueue];
|
||||
this.conversation.nextTurnQueue = [];
|
||||
if (this.nextTurnQueue.length > 0) {
|
||||
messages = [...this.nextTurnQueue, messages[0]!];
|
||||
this.nextTurnQueue = [];
|
||||
await this.emitQueueUpdate();
|
||||
}
|
||||
this.agent.state.systemPrompt = await this.resolveSystemPrompt(resources);
|
||||
const beforeResult = await this.emitHook(
|
||||
"before_agent_start",
|
||||
{
|
||||
type: "before_agent_start",
|
||||
prompt: text,
|
||||
images: options?.images,
|
||||
systemPrompt: this.agent.state.systemPrompt,
|
||||
resources,
|
||||
},
|
||||
this.agent.signal,
|
||||
);
|
||||
const beforeResult = await this.emitHook({
|
||||
type: "before_agent_start",
|
||||
prompt: text,
|
||||
images: options?.images,
|
||||
systemPrompt: turnState.systemPrompt,
|
||||
resources: turnState.resources,
|
||||
});
|
||||
if (beforeResult?.messages) messages = [...beforeResult.messages, ...messages];
|
||||
if (beforeResult?.systemPrompt) this.agent.state.systemPrompt = beforeResult.systemPrompt;
|
||||
await this.agent.prompt(messages);
|
||||
try {
|
||||
await this.agent.prompt(messages);
|
||||
} finally {
|
||||
await this.flushPendingSessionWrites();
|
||||
}
|
||||
let response: AssistantMessage | undefined;
|
||||
const newMessages = this.agent.state.messages.slice(beforeLength);
|
||||
for (let i = newMessages.length - 1; i >= 0; i--) {
|
||||
@@ -331,81 +310,98 @@ export class AgentHarness {
|
||||
return response;
|
||||
}
|
||||
|
||||
async prompt(text: string, options?: { images?: ImageContent[] }): Promise<AssistantMessage> {
|
||||
if (this.phase !== "idle") throw new Error("AgentHarness is busy");
|
||||
this.phase = "turn";
|
||||
try {
|
||||
const turnState = await this.createTurnState();
|
||||
return await this.executeTurn(turnState, text, options);
|
||||
} catch (error) {
|
||||
this.phase = "idle";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async skill(name: string, additionalInstructions?: string): Promise<AssistantMessage> {
|
||||
const resources = await this.resolveResources();
|
||||
const skill = (resources.skills ?? []).find((candidate) => candidate.name === name);
|
||||
if (!skill) throw new Error(`Unknown skill: ${name}`);
|
||||
return await this.prompt(expandSkillCommand(skill, additionalInstructions));
|
||||
if (this.phase !== "idle") throw new Error("AgentHarness is busy");
|
||||
this.phase = "turn";
|
||||
try {
|
||||
const turnState = await this.createTurnState();
|
||||
const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name);
|
||||
if (!skill) throw new Error(`Unknown skill: ${name}`);
|
||||
return await this.executeTurn(turnState, formatSkillInvocation(skill, additionalInstructions));
|
||||
} catch (error) {
|
||||
this.phase = "idle";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async promptFromTemplate(name: string, args: string[] = []): Promise<AssistantMessage> {
|
||||
const resources = await this.resolveResources();
|
||||
const template = (resources.promptTemplates ?? []).find((candidate) => candidate.name === name);
|
||||
if (!template) throw new Error(`Unknown prompt template: ${name}`);
|
||||
return await this.prompt(expandPromptTemplate(template, args));
|
||||
if (this.phase !== "idle") throw new Error("AgentHarness is busy");
|
||||
this.phase = "turn";
|
||||
try {
|
||||
const turnState = await this.createTurnState();
|
||||
const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name);
|
||||
if (!template) throw new Error(`Unknown prompt template: ${name}`);
|
||||
return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args));
|
||||
} catch (error) {
|
||||
this.phase = "idle";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
steer(message: AgentMessage): void {
|
||||
if (this.operation.idle) throw new Error("Cannot steer while idle");
|
||||
this.operation.steerQueue.push(message);
|
||||
steer(text: string, options?: { images?: ImageContent[] }): void {
|
||||
if (this.phase === "idle") throw new Error("Cannot steer while idle");
|
||||
const message = createUserMessage(text, options?.images);
|
||||
this.steerQueue.push(message);
|
||||
this.agent.steer(message);
|
||||
void this.emitQueueUpdate();
|
||||
}
|
||||
|
||||
followUp(message: AgentMessage): void {
|
||||
if (this.operation.idle) throw new Error("Cannot follow up while idle");
|
||||
this.operation.followUpQueue.push(message);
|
||||
followUp(text: string, options?: { images?: ImageContent[] }): void {
|
||||
if (this.phase === "idle") throw new Error("Cannot follow up while idle");
|
||||
const message = createUserMessage(text, options?.images);
|
||||
this.followUpQueue.push(message);
|
||||
this.agent.followUp(message);
|
||||
void this.emitQueueUpdate();
|
||||
}
|
||||
|
||||
nextTurn(message: AgentMessage): void {
|
||||
this.conversation.nextTurnQueue.push(message);
|
||||
nextTurn(text: string, options?: { images?: ImageContent[] }): void {
|
||||
this.nextTurnQueue.push(createUserMessage(text, options?.images));
|
||||
void this.emitQueueUpdate();
|
||||
}
|
||||
|
||||
async appendMessage(message: AgentMessage): Promise<void> {
|
||||
if (this.operation.idle) {
|
||||
if (this.phase === "idle") {
|
||||
await this.session.appendMessage(message);
|
||||
await this.syncFromTree();
|
||||
} else {
|
||||
this.operation.pendingMutations.appendMessages.push(message);
|
||||
this.pendingSessionWrites.push({ type: "message", message });
|
||||
}
|
||||
}
|
||||
|
||||
async shell(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
},
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
return await this.env.exec(command, options);
|
||||
}
|
||||
|
||||
async compact(
|
||||
customInstructions?: string,
|
||||
): Promise<{ summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown }> {
|
||||
if (!this.operation.idle) throw new Error("compact() requires idle harness");
|
||||
const model = this.conversation.model;
|
||||
if (this.phase !== "idle") throw new Error("compact() requires idle harness");
|
||||
this.phase = "compaction";
|
||||
const model = this.model;
|
||||
if (!model) throw new Error("No model set for compaction");
|
||||
const auth = await this.requestAuth?.(model);
|
||||
const auth = await this.getApiKeyAndHeaders?.(model);
|
||||
if (!auth) throw new Error("No auth available for compaction");
|
||||
const branchEntries = await this.session.getBranch();
|
||||
const preparation = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
|
||||
if (!preparation) throw new Error("Nothing to compact");
|
||||
const hookResult = await this.emitHook("session_before_compact", {
|
||||
const hookResult = await this.emitHook({
|
||||
type: "session_before_compact",
|
||||
preparation,
|
||||
branchEntries,
|
||||
customInstructions,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
if (hookResult?.cancel) throw new Error("Compaction cancelled");
|
||||
if (hookResult?.cancel) {
|
||||
this.phase = "idle";
|
||||
throw new Error("Compaction cancelled");
|
||||
}
|
||||
const provided = hookResult?.compaction;
|
||||
const result =
|
||||
provided ??
|
||||
@@ -416,7 +412,7 @@ export class AgentHarness {
|
||||
auth.headers,
|
||||
customInstructions,
|
||||
undefined,
|
||||
this.conversation.thinkingLevel,
|
||||
this.thinkingLevel,
|
||||
));
|
||||
const entryId = await this.session.appendCompaction(
|
||||
result.summary,
|
||||
@@ -426,10 +422,10 @@ export class AgentHarness {
|
||||
provided !== undefined,
|
||||
);
|
||||
const entry = await this.session.getEntry(entryId);
|
||||
await this.syncFromTree();
|
||||
if (entry?.type === "compaction") {
|
||||
await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined });
|
||||
}
|
||||
this.phase = "idle";
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -437,9 +433,13 @@ export class AgentHarness {
|
||||
targetId: string,
|
||||
options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },
|
||||
): Promise<NavigateTreeResult> {
|
||||
if (!this.operation.idle) throw new Error("navigateTree() requires idle harness");
|
||||
if (this.phase !== "idle") throw new Error("navigateTree() requires idle harness");
|
||||
this.phase = "branch_summary";
|
||||
const oldLeafId = await this.session.getLeafId();
|
||||
if (oldLeafId === targetId) return { cancelled: false };
|
||||
if (oldLeafId === targetId) {
|
||||
this.phase = "idle";
|
||||
return { cancelled: false };
|
||||
}
|
||||
const targetEntry = await this.session.getEntry(targetId);
|
||||
if (!targetEntry) throw new Error(`Entry ${targetId} not found`);
|
||||
const { entries, commonAncestorId } = await collectEntriesForBranchSummary(this.session, oldLeafId, targetId);
|
||||
@@ -454,19 +454,22 @@ export class AgentHarness {
|
||||
label: options?.label,
|
||||
};
|
||||
const signal = new AbortController().signal;
|
||||
const hookResult = await this.emitHook("session_before_tree", {
|
||||
const hookResult = await this.emitHook({
|
||||
type: "session_before_tree",
|
||||
preparation,
|
||||
signal,
|
||||
});
|
||||
if (hookResult?.cancel) return { cancelled: true };
|
||||
if (hookResult?.cancel) {
|
||||
this.phase = "idle";
|
||||
return { cancelled: true };
|
||||
}
|
||||
let summaryEntry: any | undefined;
|
||||
let summaryText: string | undefined = hookResult?.summary?.summary;
|
||||
let summaryDetails: unknown = hookResult?.summary?.details;
|
||||
if (!summaryText && options?.summarize && entries.length > 0) {
|
||||
const model = this.conversation.model;
|
||||
const model = this.model;
|
||||
if (!model) throw new Error("No model set for branch summary");
|
||||
const auth = await this.requestAuth?.(model);
|
||||
const auth = await this.getApiKeyAndHeaders?.(model);
|
||||
if (!auth) throw new Error("No auth available for branch summary");
|
||||
const branchSummary = await generateBranchSummary(entries, {
|
||||
model,
|
||||
@@ -476,7 +479,10 @@ export class AgentHarness {
|
||||
customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
|
||||
replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
|
||||
});
|
||||
if (branchSummary.aborted) return { cancelled: true };
|
||||
if (branchSummary.aborted) {
|
||||
this.phase = "idle";
|
||||
return { cancelled: true };
|
||||
}
|
||||
if (branchSummary.error) throw new Error(branchSummary.error);
|
||||
summaryText = branchSummary.summary;
|
||||
summaryDetails = {
|
||||
@@ -521,7 +527,6 @@ export class AgentHarness {
|
||||
if (summaryId) {
|
||||
summaryEntry = await this.session.getEntry(summaryId);
|
||||
}
|
||||
await this.syncFromTree();
|
||||
await this.emitOwn({
|
||||
type: "session_tree",
|
||||
newLeafId: await this.session.getLeafId(),
|
||||
@@ -529,48 +534,92 @@ export class AgentHarness {
|
||||
summaryEntry,
|
||||
fromHook: hookResult?.summary !== undefined,
|
||||
});
|
||||
this.phase = "idle";
|
||||
return { cancelled: false, editorText, summaryEntry };
|
||||
}
|
||||
|
||||
async setModel(model: Model<any>): Promise<void> {
|
||||
if (this.operation.idle) {
|
||||
const previousModel = this.conversation.model;
|
||||
this.conversation.model = model;
|
||||
const previousModel = this.model;
|
||||
this.model = model;
|
||||
if (this.phase === "idle") {
|
||||
this.agent.state.model = model;
|
||||
await this.session.appendModelChange(model.provider, model.id);
|
||||
await this.emitOwn({ type: "model_select", model, previousModel, source: "set" });
|
||||
} else {
|
||||
this.operation.pendingMutations.model = model;
|
||||
this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
|
||||
}
|
||||
await this.emitOwn({ type: "model_select", model, previousModel, source: "set" });
|
||||
}
|
||||
|
||||
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
|
||||
if (this.operation.idle) {
|
||||
const previousLevel = this.conversation.thinkingLevel;
|
||||
this.conversation.thinkingLevel = level;
|
||||
const previousLevel = this.thinkingLevel;
|
||||
this.thinkingLevel = level;
|
||||
if (this.phase === "idle") {
|
||||
this.agent.state.thinkingLevel = level;
|
||||
await this.session.appendThinkingLevelChange(level);
|
||||
await this.emitOwn({ type: "thinking_level_select", level, previousLevel });
|
||||
} else {
|
||||
this.operation.pendingMutations.thinkingLevel = level;
|
||||
this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
|
||||
}
|
||||
await this.emitOwn({ type: "thinking_level_select", level, previousLevel });
|
||||
}
|
||||
|
||||
async setActiveTools(toolNames: string[]): Promise<void> {
|
||||
if (this.operation.idle) {
|
||||
this.conversation.activeToolNames = [...toolNames];
|
||||
this.agent.state.tools = this.getActiveTools();
|
||||
this.validateToolNames(toolNames);
|
||||
this.activeToolNames = [...toolNames];
|
||||
if (this.phase === "idle") {
|
||||
this.agent.state.tools = this.activeToolNames.map((name) => this.tools.get(name)!);
|
||||
}
|
||||
}
|
||||
|
||||
get steeringMode(): QueueMode {
|
||||
return this.agent.steeringMode;
|
||||
}
|
||||
|
||||
set steeringMode(mode: QueueMode) {
|
||||
this.agent.steeringMode = mode;
|
||||
}
|
||||
|
||||
get followUpMode(): QueueMode {
|
||||
return this.agent.followUpMode;
|
||||
}
|
||||
|
||||
set followUpMode(mode: QueueMode) {
|
||||
this.agent.followUpMode = mode;
|
||||
}
|
||||
|
||||
getResources(): AgentHarnessResources<TSkill, TPromptTemplate> {
|
||||
return {
|
||||
skills: this.resources.skills?.slice(),
|
||||
promptTemplates: this.resources.promptTemplates?.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
async setResources(resources: AgentHarnessResources<TSkill, TPromptTemplate>): Promise<void> {
|
||||
const previousResources = this.getResources();
|
||||
this.resources = {
|
||||
skills: resources.skills?.slice(),
|
||||
promptTemplates: resources.promptTemplates?.slice(),
|
||||
};
|
||||
await this.emitOwn({ type: "resources_update", resources: this.getResources(), previousResources });
|
||||
}
|
||||
|
||||
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
|
||||
this.tools = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
if (activeToolNames) {
|
||||
this.validateToolNames(activeToolNames);
|
||||
this.activeToolNames = [...activeToolNames];
|
||||
} else {
|
||||
this.operation.pendingMutations.activeToolNames = [...toolNames];
|
||||
this.validateToolNames(this.activeToolNames);
|
||||
}
|
||||
if (this.phase === "idle") {
|
||||
this.agent.state.tools = this.activeToolNames.map((name) => this.tools.get(name)!);
|
||||
}
|
||||
}
|
||||
|
||||
async abort(): Promise<AbortResult> {
|
||||
this.operation.abortRequested = true;
|
||||
const clearedSteer = [...this.operation.steerQueue];
|
||||
const clearedFollowUp = [...this.operation.followUpQueue];
|
||||
this.operation.steerQueue = [];
|
||||
this.operation.followUpQueue = [];
|
||||
const clearedSteer = [...this.steerQueue];
|
||||
const clearedFollowUp = [...this.followUpQueue];
|
||||
this.steerQueue = [];
|
||||
this.followUpQueue = [];
|
||||
this.agent.clearAllQueues();
|
||||
await this.emitQueueUpdate();
|
||||
this.agent.abort();
|
||||
@@ -583,7 +632,9 @@ export class AgentHarness {
|
||||
await this.agent.waitForIdle();
|
||||
}
|
||||
|
||||
subscribe(listener: (event: AgentHarnessEvent, signal?: AbortSignal) => Promise<void> | void): () => void {
|
||||
subscribe(
|
||||
listener: (event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal) => Promise<void> | void,
|
||||
): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
@@ -591,8 +642,7 @@ export class AgentHarness {
|
||||
on<TType extends keyof AgentHarnessEventResultMap>(
|
||||
type: TType,
|
||||
handler: (
|
||||
event: Extract<import("./types.js").AgentHarnessOwnEvent, { type: TType }>,
|
||||
ctx: AgentHarnessContext,
|
||||
event: Extract<AgentHarnessOwnEvent, { type: TType }>,
|
||||
) => Promise<AgentHarnessEventResultMap[TType]> | AgentHarnessEventResultMap[TType],
|
||||
): () => void {
|
||||
let handlers = this.hooks.get(type);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { AgentHarness } from "./agent-harness.js";
|
||||
import { Session } from "./session/session.js";
|
||||
import type { AgentHarnessOptions, SessionMetadata, SessionStorage } from "./types.js";
|
||||
|
||||
export function createSession<TMetadata extends SessionMetadata>(
|
||||
storage: SessionStorage<TMetadata>,
|
||||
): Session<TMetadata> {
|
||||
return new Session(storage);
|
||||
}
|
||||
|
||||
export function createAgentHarness(options: AgentHarnessOptions): AgentHarness {
|
||||
return new AgentHarness(options);
|
||||
}
|
||||
@@ -52,19 +52,26 @@ export async function loadPromptTemplates(
|
||||
* Source values are preserved exactly and attached to every loaded prompt template and diagnostic. The agent package does
|
||||
* not interpret source values; applications define their own provenance shape.
|
||||
*/
|
||||
export async function loadSourcedPromptTemplates<TSource>(
|
||||
export async function loadSourcedPromptTemplates<TSource, TPromptTemplate extends PromptTemplate = PromptTemplate>(
|
||||
env: ExecutionEnv,
|
||||
inputs: Array<{ path: string; source: TSource }>,
|
||||
mapPromptTemplate?: (promptTemplate: PromptTemplate, source: TSource) => TPromptTemplate,
|
||||
): Promise<{
|
||||
promptTemplates: Array<{ promptTemplate: PromptTemplate; source: TSource }>;
|
||||
promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }>;
|
||||
diagnostics: Array<PromptTemplateDiagnostic & { source: TSource }>;
|
||||
}> {
|
||||
const promptTemplates: Array<{ promptTemplate: PromptTemplate; source: TSource }> = [];
|
||||
const promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }> = [];
|
||||
const diagnostics: Array<PromptTemplateDiagnostic & { source: TSource }> = [];
|
||||
for (const input of inputs) {
|
||||
const result = await loadPromptTemplates(env, input.path);
|
||||
for (const promptTemplate of result.promptTemplates)
|
||||
promptTemplates.push({ promptTemplate, source: input.source });
|
||||
for (const promptTemplate of result.promptTemplates) {
|
||||
promptTemplates.push({
|
||||
promptTemplate: mapPromptTemplate
|
||||
? mapPromptTemplate(promptTemplate, input.source)
|
||||
: (promptTemplate as TPromptTemplate),
|
||||
source: input.source,
|
||||
});
|
||||
}
|
||||
for (const diagnostic of result.diagnostics) diagnostics.push({ ...diagnostic, source: input.source });
|
||||
}
|
||||
return { promptTemplates, diagnostics };
|
||||
@@ -169,7 +176,7 @@ function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
/** Parse slash-command arguments using simple shell-style single and double quotes. */
|
||||
/** Parse an argument string using simple shell-style single and double quotes. */
|
||||
export function parseCommandArgs(argsString: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = "";
|
||||
@@ -211,7 +218,7 @@ export function substituteArgs(content: string, args: string[]): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Expand a prompt template with positional command arguments. */
|
||||
export function expandPromptTemplate(template: PromptTemplate, args: string[] = []): string {
|
||||
/** Format a prompt template invocation with positional arguments. */
|
||||
export function formatPromptTemplateInvocation(template: PromptTemplate, args: string[] = []): string {
|
||||
return substituteArgs(template.content, args);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ interface SkillFrontmatter {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Expand a skill into a prompt, optionally appending additional user instructions. */
|
||||
export function expandSkillCommand(skill: Skill, additionalInstructions?: string): string {
|
||||
/** Format a skill invocation prompt, optionally appending additional user instructions. */
|
||||
export function formatSkillInvocation(skill: Skill, additionalInstructions?: string): string {
|
||||
const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${dirnameEnvPath(skill.filePath)}.\n\n${skill.content}\n</skill>`;
|
||||
return additionalInstructions ? `${skillBlock}\n\n${additionalInstructions}` : skillBlock;
|
||||
}
|
||||
@@ -59,18 +59,21 @@ export async function loadSkills(
|
||||
* Source values are preserved exactly and attached to every loaded skill and diagnostic. The agent package does not
|
||||
* interpret source values; applications define their own provenance shape.
|
||||
*/
|
||||
export async function loadSourcedSkills<TSource>(
|
||||
export async function loadSourcedSkills<TSource, TSkill extends Skill = Skill>(
|
||||
env: ExecutionEnv,
|
||||
inputs: Array<{ path: string; source: TSource }>,
|
||||
mapSkill?: (skill: Skill, source: TSource) => TSkill,
|
||||
): Promise<{
|
||||
skills: Array<{ skill: Skill; source: TSource }>;
|
||||
skills: Array<{ skill: TSkill; source: TSource }>;
|
||||
diagnostics: Array<SkillDiagnostic & { source: TSource }>;
|
||||
}> {
|
||||
const skills: Array<{ skill: Skill; source: TSource }> = [];
|
||||
const skills: Array<{ skill: TSkill; source: TSource }> = [];
|
||||
const diagnostics: Array<SkillDiagnostic & { source: TSource }> = [];
|
||||
for (const input of inputs) {
|
||||
const result = await loadSkills(env, input.path);
|
||||
for (const skill of result.skills) skills.push({ skill, source: input.source });
|
||||
for (const skill of result.skills) {
|
||||
skills.push({ skill: mapSkill ? mapSkill(skill, input.source) : (skill as TSkill), source: input.source });
|
||||
}
|
||||
for (const diagnostic of result.diagnostics) diagnostics.push({ ...diagnostic, source: input.source });
|
||||
}
|
||||
return { skills, diagnostics };
|
||||
|
||||
@@ -6,7 +6,7 @@ export function formatSkillsForSystemPrompt(skills: Skill[]): string {
|
||||
|
||||
const lines = [
|
||||
"The following skills provide specialized instructions for specific tasks.",
|
||||
"Use the read tool to load a skill's file when the task matches its description.",
|
||||
"Read the full skill file when the task matches its description.",
|
||||
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
|
||||
"",
|
||||
"<available_skills>",
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { QueueMode } from "../agent.js";
|
||||
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../index.js";
|
||||
import type { Session } from "./session/session.js";
|
||||
|
||||
/** Model-visible skill loaded from a `SKILL.md` file or provided by an application. */
|
||||
/**
|
||||
* Skill loaded from a `SKILL.md` file or provided by an application.
|
||||
*
|
||||
* `name`, `description`, and `filePath` are inserted into the system prompt in an XML-formatted block as suggested by agentskills.io.
|
||||
* Use {@link formatSkillsForSystemPrompt} to generate the spec-compatible system prompt block.
|
||||
*/
|
||||
export interface Skill {
|
||||
/** Skill command name. */
|
||||
/** Stable skill name used for lookup and model-visible listings. */
|
||||
name: string;
|
||||
/** Short model-visible description of when to use the skill. */
|
||||
description: string;
|
||||
@@ -16,22 +22,25 @@ export interface Skill {
|
||||
disableModelInvocation?: boolean;
|
||||
}
|
||||
|
||||
/** Prompt template that can expand slash-command text before a prompt is sent. */
|
||||
/** Prompt template that can be formatted into a prompt for explicit invocation. */
|
||||
export interface PromptTemplate {
|
||||
/** Slash-command name without the leading `/`. */
|
||||
/** Stable template name used for lookup or application command routing. */
|
||||
name: string;
|
||||
/** Optional description for command lists or autocomplete. */
|
||||
description?: string;
|
||||
/** Template content. Argument placeholders are expanded by `expandPromptTemplate`. */
|
||||
/** Template content. Argument placeholders are formatted by `formatPromptTemplateInvocation`. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Resources made available to harness prompt expansion and system-prompt callbacks. */
|
||||
export interface AgentHarnessResources {
|
||||
/** Prompt templates used to expand `/template args` input. */
|
||||
promptTemplates?: PromptTemplate[];
|
||||
/** Resources made available to explicit invocation methods and system-prompt callbacks. */
|
||||
export interface AgentHarnessResources<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> {
|
||||
/** Prompt templates available for explicit invocation. */
|
||||
promptTemplates?: TPromptTemplate[];
|
||||
/** Skills available to the model and explicit skill invocation. */
|
||||
skills?: Skill[];
|
||||
skills?: TSkill[];
|
||||
}
|
||||
|
||||
/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */
|
||||
@@ -281,43 +290,26 @@ export interface JsonlSessionListOptions {
|
||||
export interface JsonlSessionRepoApi
|
||||
extends SessionRepo<JsonlSessionMetadata, JsonlSessionCreateOptions, JsonlSessionListOptions> {}
|
||||
|
||||
export interface AgentHarnessPendingMutations {
|
||||
appendMessages: AgentMessage[];
|
||||
model?: Model<any>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
activeToolNames?: string[];
|
||||
}
|
||||
export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry";
|
||||
|
||||
export interface AgentHarnessConversationState {
|
||||
session: Session;
|
||||
export type PendingSessionWrite = SessionTreeEntry extends infer TEntry
|
||||
? TEntry extends SessionTreeEntry
|
||||
? Omit<TEntry, "id" | "parentId" | "timestamp">
|
||||
: never
|
||||
: never;
|
||||
|
||||
export interface AgentHarnessTurnState<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentTool = AgentTool,
|
||||
> {
|
||||
messages: AgentMessage[];
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
systemPrompt: string;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
activeToolNames: string[];
|
||||
nextTurnQueue: AgentMessage[];
|
||||
}
|
||||
|
||||
export interface AgentHarnessOperationState {
|
||||
idle: boolean;
|
||||
liveOperationId?: string;
|
||||
abortRequested: boolean;
|
||||
steerQueue: AgentMessage[];
|
||||
followUpQueue: AgentMessage[];
|
||||
pendingMutations: AgentHarnessPendingMutations;
|
||||
}
|
||||
|
||||
export interface SavePointSnapshot {
|
||||
messages: AgentMessage[];
|
||||
model: Model<any> | undefined;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
activeToolNames: string[];
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
export interface AgentHarnessContext {
|
||||
env: ExecutionEnv;
|
||||
conversation: AgentHarnessConversationState;
|
||||
operation: AgentHarnessOperationState;
|
||||
abortSignal?: AbortSignal;
|
||||
tools: TTool[];
|
||||
activeTools: TTool[];
|
||||
}
|
||||
|
||||
export interface QueueUpdateEvent {
|
||||
@@ -329,7 +321,6 @@ export interface QueueUpdateEvent {
|
||||
|
||||
export interface SavePointEvent {
|
||||
type: "save_point";
|
||||
liveOperationId: string;
|
||||
hadPendingMutations: boolean;
|
||||
}
|
||||
|
||||
@@ -344,12 +335,15 @@ export interface SettledEvent {
|
||||
nextTurnCount: number;
|
||||
}
|
||||
|
||||
export interface BeforeAgentStartEvent {
|
||||
export interface BeforeAgentStartEvent<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> {
|
||||
type: "before_agent_start";
|
||||
prompt: string;
|
||||
images?: ImageContent[];
|
||||
systemPrompt: string;
|
||||
resources: AgentHarnessResources;
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
}
|
||||
|
||||
export interface ContextEvent {
|
||||
@@ -426,12 +420,24 @@ export interface ThinkingLevelSelectEvent {
|
||||
previousLevel: ThinkingLevel;
|
||||
}
|
||||
|
||||
export type AgentHarnessOwnEvent =
|
||||
export interface ResourcesUpdateEvent<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> {
|
||||
type: "resources_update";
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
previousResources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
}
|
||||
|
||||
export type AgentHarnessOwnEvent<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> =
|
||||
| QueueUpdateEvent
|
||||
| SavePointEvent
|
||||
| AbortEvent
|
||||
| SettledEvent
|
||||
| BeforeAgentStartEvent
|
||||
| BeforeAgentStartEvent<TSkill, TPromptTemplate>
|
||||
| ContextEvent
|
||||
| BeforeProviderRequestEvent
|
||||
| AfterProviderResponseEvent
|
||||
@@ -442,9 +448,12 @@ export type AgentHarnessOwnEvent =
|
||||
| SessionBeforeTreeEvent
|
||||
| SessionTreeEvent
|
||||
| ModelSelectEvent
|
||||
| ThinkingLevelSelectEvent;
|
||||
| ThinkingLevelSelectEvent
|
||||
| ResourcesUpdateEvent<TSkill, TPromptTemplate>;
|
||||
|
||||
export type AgentHarnessEvent = AgentEvent | AgentHarnessOwnEvent;
|
||||
export type AgentHarnessEvent<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate> =
|
||||
| AgentEvent
|
||||
| AgentHarnessOwnEvent<TSkill, TPromptTemplate>;
|
||||
|
||||
export interface BeforeAgentStartResult {
|
||||
messages?: AgentMessage[];
|
||||
@@ -497,6 +506,7 @@ export type AgentHarnessEventResultMap = {
|
||||
session_tree: undefined;
|
||||
model_select: undefined;
|
||||
thinking_level_select: undefined;
|
||||
resources_update: undefined;
|
||||
queue_update: undefined;
|
||||
save_point: undefined;
|
||||
abort: undefined;
|
||||
@@ -577,13 +587,19 @@ export interface BranchSummaryResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AgentHarnessOptions {
|
||||
export interface AgentHarnessOptions<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentTool = AgentTool,
|
||||
> {
|
||||
env: ExecutionEnv;
|
||||
session: Session;
|
||||
tools?: AgentTool[];
|
||||
resources?:
|
||||
| AgentHarnessResources
|
||||
| ((context: AgentHarnessContext) => AgentHarnessResources | Promise<AgentHarnessResources>);
|
||||
tools?: TTool[];
|
||||
/**
|
||||
* Concrete resources available to explicit invocation methods and system-prompt callbacks.
|
||||
* Applications own loading/reloading resources and should call `setResources()` with new values.
|
||||
*/
|
||||
resources?: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
systemPrompt?:
|
||||
| string
|
||||
| ((context: {
|
||||
@@ -591,13 +607,17 @@ export interface AgentHarnessOptions {
|
||||
session: Session;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
activeTools: AgentTool[];
|
||||
resources: AgentHarnessResources;
|
||||
activeTools: TTool[];
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
}) => string | Promise<string>);
|
||||
requestAuth?: (model: Model<any>) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
|
||||
getApiKeyAndHeaders?: (
|
||||
model: Model<any>,
|
||||
) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
|
||||
model: Model<any>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
activeToolNames?: string[];
|
||||
steeringMode?: QueueMode;
|
||||
followUpMode?: QueueMode;
|
||||
}
|
||||
|
||||
export type { AgentHarness } from "./agent-harness.js";
|
||||
|
||||
@@ -23,7 +23,6 @@ export {
|
||||
shouldCompact,
|
||||
} from "./harness/compaction/compaction.js";
|
||||
export * from "./harness/execution-env.js";
|
||||
export * from "./harness/factory.js";
|
||||
export * from "./harness/messages.js";
|
||||
export * from "./harness/prompt-templates.js";
|
||||
export * from "./harness/session/repo/jsonl.js";
|
||||
|
||||
@@ -112,6 +112,18 @@ export interface ShouldStopAfterTurnContext {
|
||||
newMessages: AgentMessage[];
|
||||
}
|
||||
|
||||
/** Replacement runtime state used by the agent loop before starting another provider request. */
|
||||
export interface AgentLoopTurnUpdate {
|
||||
/** Context for the next provider request. */
|
||||
context?: AgentContext;
|
||||
/** Model for the next provider request. */
|
||||
model?: Model<any>;
|
||||
/** Thinking level for the next provider request. */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {}
|
||||
|
||||
export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
model: Model<any>;
|
||||
|
||||
@@ -187,6 +199,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
*/
|
||||
shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Called after `turn_end` and before the loop decides whether another provider request should start.
|
||||
* Return replacement context/model/thinking state to affect the next turn in this run.
|
||||
* Return undefined to keep using the current context/config.
|
||||
*/
|
||||
prepareNextTurn?: (
|
||||
context: PrepareNextTurnContext,
|
||||
) => AgentLoopTurnUpdate | undefined | Promise<AgentLoopTurnUpdate | undefined>;
|
||||
|
||||
/**
|
||||
* Returns steering messages to inject into the conversation mid-run.
|
||||
*
|
||||
|
||||
@@ -894,6 +894,79 @@ describe("agentLoop with AgentMessage", () => {
|
||||
expect(parallelObserved).toBe(true);
|
||||
});
|
||||
|
||||
it("should use prepareNextTurn snapshot before continuing", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const tool: AgentTool<typeof toolSchema, { value: string }> = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo tool",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
return {
|
||||
content: [{ type: "text", text: `echoed: ${params.value}` }],
|
||||
details: { value: params.value },
|
||||
};
|
||||
},
|
||||
};
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "first prompt",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
let convertedSecondTurnSystemPrompt = "";
|
||||
let prepared = false;
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
prepareNextTurn: async ({ context: currentContext }) => {
|
||||
if (prepared) return undefined;
|
||||
prepared = true;
|
||||
return {
|
||||
context: {
|
||||
systemPrompt: "second prompt",
|
||||
messages: currentContext.messages.slice(),
|
||||
tools: currentContext.tools,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
let llmCalls = 0;
|
||||
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, (_model, ctx) => {
|
||||
llmCalls++;
|
||||
if (llmCalls === 2) {
|
||||
convertedSecondTurnSystemPrompt = ctx.systemPrompt ?? "";
|
||||
}
|
||||
const mockStream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
if (llmCalls === 1) {
|
||||
mockStream.push({
|
||||
type: "done",
|
||||
reason: "toolUse",
|
||||
message: createAssistantMessage(
|
||||
[{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
|
||||
"toolUse",
|
||||
),
|
||||
});
|
||||
} else {
|
||||
mockStream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: createAssistantMessage([{ type: "text", text: "done" }]),
|
||||
});
|
||||
}
|
||||
});
|
||||
return mockStream;
|
||||
});
|
||||
|
||||
for await (const _event of stream) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(llmCalls).toBe(2);
|
||||
expect(convertedSecondTurnSystemPrompt).toBe("second prompt");
|
||||
});
|
||||
|
||||
it("should stop after the current turn when shouldStopAfterTurn returns true", async () => {
|
||||
const toolSchema = Type.Object({ value: Type.String() });
|
||||
const executed: string[] = [];
|
||||
|
||||
82
packages/agent/test/harness/agent-harness.test.ts
Normal file
82
packages/agent/test/harness/agent-harness.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { getModel } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AgentHarness } from "../../src/harness/agent-harness.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { Session } from "../../src/harness/session/session.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import type { PromptTemplate, Skill } from "../../src/harness/types.js";
|
||||
import type { AgentTool } from "../../src/types.js";
|
||||
|
||||
interface AppSkill extends Skill {
|
||||
source: "project" | "user";
|
||||
}
|
||||
|
||||
interface AppPromptTemplate extends PromptTemplate {
|
||||
source: "project" | "user";
|
||||
}
|
||||
|
||||
interface AppTool extends AgentTool {
|
||||
source: "builtin" | "extension";
|
||||
}
|
||||
|
||||
describe("AgentHarness", () => {
|
||||
it("constructs directly and exposes queue modes", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const initialModel = getModel("anthropic", "claude-sonnet-4-5");
|
||||
const harness = new AgentHarness({
|
||||
env,
|
||||
session,
|
||||
model: initialModel,
|
||||
systemPrompt: "You are helpful.",
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
});
|
||||
expect(harness.env).toBe(env);
|
||||
expect(harness.agent.state.model).toBe(initialModel);
|
||||
expect(harness.steeringMode).toBe("all");
|
||||
expect(harness.followUpMode).toBe("all");
|
||||
harness.steeringMode = "one-at-a-time";
|
||||
harness.followUpMode = "one-at-a-time";
|
||||
expect(harness.agent.steeringMode).toBe("one-at-a-time");
|
||||
expect(harness.agent.followUpMode).toBe("one-at-a-time");
|
||||
});
|
||||
|
||||
it("preserves app resource types for getters and update events", async () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({ env, session, model });
|
||||
const skill: AppSkill = {
|
||||
name: "inspect",
|
||||
description: "Inspect things",
|
||||
content: "Use inspection tools.",
|
||||
filePath: "/skills/inspect/SKILL.md",
|
||||
source: "project",
|
||||
};
|
||||
const promptTemplate: AppPromptTemplate = { name: "review", content: "Review $1", source: "user" };
|
||||
const resources = { skills: [skill], promptTemplates: [promptTemplate] };
|
||||
const updates: Array<{ resourcesSource?: string; previousSource?: string }> = [];
|
||||
harness.subscribe((event) => {
|
||||
if (event.type === "resources_update") {
|
||||
updates.push({
|
||||
resourcesSource: event.resources.skills?.[0]?.source,
|
||||
previousSource: event.previousResources.skills?.[0]?.source,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await harness.setResources(resources);
|
||||
await harness.setResources(resources);
|
||||
const resolved = harness.getResources();
|
||||
|
||||
expect(updates).toEqual([
|
||||
{ resourcesSource: "project", previousSource: undefined },
|
||||
{ resourcesSource: "project", previousSource: "project" },
|
||||
]);
|
||||
expect(resolved.skills?.[0]?.source).toBe("project");
|
||||
expect(resolved.promptTemplates?.[0]?.source).toBe("user");
|
||||
expect(resolved.skills).not.toBe(resources.skills);
|
||||
expect(resolved.promptTemplates).not.toBe(resources.promptTemplates);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { getModel } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { createAgentHarness, createSession } from "../../src/harness/factory.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
|
||||
describe("harness factories", () => {
|
||||
it("creates sessions from storage", async () => {
|
||||
const storage = new InMemorySessionStorage({
|
||||
metadata: { id: "session-1", createdAt: "2026-01-01T00:00:00.000Z" },
|
||||
});
|
||||
const session = createSession(storage);
|
||||
expect(session.getStorage()).toBe(storage);
|
||||
expect(await session.getMetadata()).toEqual({ id: "session-1", createdAt: "2026-01-01T00:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("creates agent harnesses", () => {
|
||||
const session = createSession(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const initialModel = getModel("anthropic", "claude-sonnet-4-5");
|
||||
const harness = createAgentHarness({ env, session, model: initialModel, systemPrompt: "You are helpful." });
|
||||
expect(harness.env).toBe(env);
|
||||
expect(harness.conversation.session).toBe(session);
|
||||
expect(harness.conversation.model).toBe(initialModel);
|
||||
expect(harness.agent.state.model).toBe(initialModel);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import {
|
||||
expandPromptTemplate,
|
||||
formatPromptTemplateInvocation,
|
||||
loadPromptTemplates,
|
||||
loadSourcedPromptTemplates,
|
||||
} from "../../src/harness/prompt-templates.js";
|
||||
@@ -80,10 +80,10 @@ describe("loadPromptTemplates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandPromptTemplate", () => {
|
||||
describe("formatPromptTemplateInvocation", () => {
|
||||
it("substitutes command arguments", () => {
|
||||
const content = "$1 $" + "{@:2} $ARGUMENTS";
|
||||
expect(expandPromptTemplate({ name: "one", content }, ["hello world", "test"])).toBe(
|
||||
expect(formatPromptTemplateInvocation({ name: "one", content }, ["hello world", "test"])).toBe(
|
||||
"hello world test hello world test",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expandPromptTemplate } from "../../src/harness/prompt-templates.js";
|
||||
import { expandSkillCommand } from "../../src/harness/skills.js";
|
||||
|
||||
describe("resource expansion helpers", () => {
|
||||
it("expands skills with additional instructions", () => {
|
||||
const skill = {
|
||||
name: "inspect",
|
||||
description: "Inspect things",
|
||||
content: "Use inspection tools.",
|
||||
filePath: "/project/.pi/skills/inspect/SKILL.md",
|
||||
};
|
||||
|
||||
expect(expandSkillCommand(skill, "Check errors.")).toBe(
|
||||
'<skill name="inspect" location="/project/.pi/skills/inspect/SKILL.md">\nReferences are relative to /project/.pi/skills/inspect.\n\nUse inspection tools.\n</skill>\n\nCheck errors.',
|
||||
);
|
||||
});
|
||||
|
||||
it("expands prompt templates with positional arguments", () => {
|
||||
expect(expandPromptTemplate({ name: "review", content: "Review $1 with $ARGUMENTS" }, ["a.ts", "care"])).toBe(
|
||||
"Review a.ts with a.ts care",
|
||||
);
|
||||
});
|
||||
});
|
||||
24
packages/agent/test/harness/resource-formatting.test.ts
Normal file
24
packages/agent/test/harness/resource-formatting.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.js";
|
||||
import { formatSkillInvocation } from "../../src/harness/skills.js";
|
||||
|
||||
describe("resource formatting helpers", () => {
|
||||
it("formats skill invocations with additional instructions", () => {
|
||||
const skill = {
|
||||
name: "inspect",
|
||||
description: "Inspect things",
|
||||
content: "Use inspection tools.",
|
||||
filePath: "/project/.pi/skills/inspect/SKILL.md",
|
||||
};
|
||||
|
||||
expect(formatSkillInvocation(skill, "Check errors.")).toBe(
|
||||
'<skill name="inspect" location="/project/.pi/skills/inspect/SKILL.md">\nReferences are relative to /project/.pi/skills/inspect.\n\nUse inspection tools.\n</skill>\n\nCheck errors.',
|
||||
);
|
||||
});
|
||||
|
||||
it("formats prompt template invocations with positional arguments", () => {
|
||||
expect(
|
||||
formatPromptTemplateInvocation({ name: "review", content: "Review $1 with $ARGUMENTS" }, ["a.ts", "care"]),
|
||||
).toBe("Review a.ts with a.ts care");
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.js";
|
||||
|
||||
const visibleSkill = {
|
||||
name: "visible",
|
||||
description: "Use <this> & that",
|
||||
content: "visible content",
|
||||
filePath: "/skills/visible/SKILL.md",
|
||||
};
|
||||
|
||||
const secondSkill = {
|
||||
name: "second",
|
||||
description: "Second skill",
|
||||
content: "second content",
|
||||
filePath: "/skills/second/SKILL.md",
|
||||
};
|
||||
|
||||
const disabledSkill = {
|
||||
name: "hidden",
|
||||
description: "Hidden",
|
||||
content: "hidden content",
|
||||
filePath: "/skills/hidden/SKILL.md",
|
||||
disableModelInvocation: true,
|
||||
};
|
||||
|
||||
describe("formatSkillsForSystemPrompt", () => {
|
||||
it("formats visible skills and skips model-disabled skills", () => {
|
||||
it("formats visible skills in order and skips model-disabled skills", () => {
|
||||
expect(formatSkillsForSystemPrompt([visibleSkill, disabledSkill, secondSkill])).toBe(
|
||||
`The following skills provide specialized instructions for specific tasks.
|
||||
Read the full skill file when the task matches its description.
|
||||
When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.
|
||||
|
||||
<available_skills>
|
||||
<skill>
|
||||
<name>visible</name>
|
||||
<description>Use <this> & that</description>
|
||||
<location>/skills/visible/SKILL.md</location>
|
||||
</skill>
|
||||
<skill>
|
||||
<name>second</name>
|
||||
<description>Second skill</description>
|
||||
<location>/skills/second/SKILL.md</location>
|
||||
</skill>
|
||||
</available_skills>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an empty string when no skills are model-visible", () => {
|
||||
expect(formatSkillsForSystemPrompt([disabledSkill])).toBe("");
|
||||
});
|
||||
|
||||
it("escapes XML in all model-visible skill fields", () => {
|
||||
expect(
|
||||
formatSkillsForSystemPrompt([
|
||||
{
|
||||
name: "visible",
|
||||
description: "Use <this> & that",
|
||||
content: "visible content",
|
||||
filePath: "/skills/visible/SKILL.md",
|
||||
},
|
||||
{
|
||||
name: "hidden",
|
||||
description: "Hidden",
|
||||
content: "hidden content",
|
||||
filePath: "/skills/hidden/SKILL.md",
|
||||
disableModelInvocation: true,
|
||||
name: "a&b",
|
||||
description: `Quote "double" and 'single'`,
|
||||
content: "content",
|
||||
filePath: '/skills/<bad>&"quote"/SKILL.md',
|
||||
},
|
||||
]),
|
||||
).toContain("<name>visible</name>");
|
||||
expect(
|
||||
formatSkillsForSystemPrompt([
|
||||
{
|
||||
name: "visible",
|
||||
description: "Use <this> & that",
|
||||
content: "visible content",
|
||||
filePath: "/skills/visible/SKILL.md",
|
||||
},
|
||||
]),
|
||||
).toContain("Use <this> & that");
|
||||
expect(
|
||||
formatSkillsForSystemPrompt([
|
||||
{
|
||||
name: "hidden",
|
||||
description: "Hidden",
|
||||
content: "hidden content",
|
||||
filePath: "/skills/hidden/SKILL.md",
|
||||
disableModelInvocation: true,
|
||||
},
|
||||
]),
|
||||
).toBe("");
|
||||
).toContain(
|
||||
"<name>a&b</name>\n <description>Quote "double" and 'single'</description>\n <location>/skills/<bad>&"quote"/SKILL.md</location>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,30 +3,39 @@ import { join } from "node:path";
|
||||
import { getModel } from "@earendil-works/pi-ai";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import {
|
||||
createAgentHarness,
|
||||
AgentHarness,
|
||||
formatSkillsForSystemPrompt,
|
||||
loadSourcedPromptTemplates,
|
||||
loadSourcedSkills,
|
||||
NodeExecutionEnv,
|
||||
type PromptTemplate,
|
||||
Session,
|
||||
type Skill,
|
||||
} from "../../src/index.js";
|
||||
|
||||
type Source = { type: "project" | "user" | "path"; dir: string };
|
||||
type SourcedSkill = Skill & { source: Source };
|
||||
type SourcedPromptTemplate = PromptTemplate & { source: Source };
|
||||
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const source = (type: Source["type"], dir: string) => ({ path: dir, source: { type, dir } });
|
||||
const { skills: sourcedSkills } = await loadSourcedSkills<Source>(env, [
|
||||
source("project", join(env.cwd, ".pi/skills")),
|
||||
source("user", join(homedir(), ".pi/agent/skills")),
|
||||
source("path", join(env.cwd, "../../../pi-skills")),
|
||||
]);
|
||||
const { promptTemplates: sourcedPromptTemplates } = await loadSourcedPromptTemplates<Source>(env, [
|
||||
source("project", join(env.cwd, ".pi/prompts")),
|
||||
source("user", join(homedir(), ".pi/agent/prompts")),
|
||||
]);
|
||||
const { skills: sourcedSkills } = await loadSourcedSkills<Source, SourcedSkill>(
|
||||
env,
|
||||
[
|
||||
source("project", join(env.cwd, ".pi/skills")),
|
||||
source("user", join(homedir(), ".pi/agent/skills")),
|
||||
source("path", join(env.cwd, "../../../pi-skills")),
|
||||
],
|
||||
(skill, source) => ({ ...skill, source }),
|
||||
);
|
||||
const { promptTemplates: sourcedPromptTemplates } = await loadSourcedPromptTemplates<Source, SourcedPromptTemplate>(
|
||||
env,
|
||||
[source("project", join(env.cwd, ".pi/prompts")), source("user", join(homedir(), ".pi/agent/prompts"))],
|
||||
(promptTemplate, source) => ({ ...promptTemplate, source }),
|
||||
);
|
||||
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const agent = createAgentHarness({
|
||||
const agent = new AgentHarness({
|
||||
env,
|
||||
session,
|
||||
model: getModel("openai", "gpt-5.5"),
|
||||
|
||||
@@ -90,7 +90,7 @@ function hasVertexAdcCredentials(): boolean {
|
||||
|
||||
function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
||||
if (provider === "github-copilot") {
|
||||
return ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
||||
return ["COPILOT_GITHUB_TOKEN"];
|
||||
}
|
||||
|
||||
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
import type { ImagesApi, ImagesModel } from "./types.js";
|
||||
|
||||
export const IMAGE_MODELS = {
|
||||
openrouter: {
|
||||
"openrouter": {
|
||||
"black-forest-labs/flux.2-flex": {
|
||||
id: "black-forest-labs/flux.2-flex",
|
||||
name: "Black Forest Labs: FLUX.2 Flex",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-klein-4b": {
|
||||
id: "black-forest-labs/flux.2-klein-4b",
|
||||
@@ -26,14 +26,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-max": {
|
||||
id: "black-forest-labs/flux.2-max",
|
||||
@@ -41,14 +41,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-pro": {
|
||||
id: "black-forest-labs/flux.2-pro",
|
||||
@@ -56,14 +56,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"bytedance-seed/seedream-4.5": {
|
||||
id: "bytedance-seed/seedream-4.5",
|
||||
@@ -71,14 +71,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
input: ["image","text"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-2.5-flash-image": {
|
||||
id: "google/gemini-2.5-flash-image",
|
||||
@@ -86,14 +86,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.3,
|
||||
output: 2.5,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.08333333333333334,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 0.3,
|
||||
"output": 2.5,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0.08333333333333334
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3-pro-image-preview": {
|
||||
id: "google/gemini-3-pro-image-preview",
|
||||
@@ -101,14 +101,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.19999999999999998,
|
||||
cacheWrite: 0.375,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 2,
|
||||
"output": 12,
|
||||
"cacheRead": 0.19999999999999998,
|
||||
"cacheWrite": 0.375
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3.1-flash-image-preview": {
|
||||
id: "google/gemini-3.1-flash-image-preview",
|
||||
@@ -116,14 +116,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.5,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 0.5,
|
||||
"output": 3,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image": {
|
||||
id: "openai/gpt-5-image",
|
||||
@@ -131,14 +131,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 1.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 10,
|
||||
"output": 10,
|
||||
"cacheRead": 1.25,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image-mini": {
|
||||
id: "openai/gpt-5-image-mini",
|
||||
@@ -146,14 +146,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 2,
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 2.5,
|
||||
"output": 2,
|
||||
"cacheRead": 0.25,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5.4-image-2": {
|
||||
id: "openai/gpt-5.4-image-2",
|
||||
@@ -161,14 +161,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 8,
|
||||
output: 15,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 8,
|
||||
"output": 15,
|
||||
"cacheRead": 2,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openrouter/auto": {
|
||||
id: "openrouter/auto",
|
||||
@@ -176,14 +176,59 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["text", "image"],
|
||||
cost: {
|
||||
input: -1000000,
|
||||
output: -1000000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
input: ["text","image"],
|
||||
output: ["text","image"],
|
||||
cost: {
|
||||
"input": -1000000,
|
||||
"output": -1000000,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v3": {
|
||||
id: "recraft/recraft-v3",
|
||||
name: "Recraft: Recraft V3",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4": {
|
||||
id: "recraft/recraft-v4",
|
||||
name: "Recraft: Recraft V4",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-pro": {
|
||||
id: "recraft/recraft-v4-pro",
|
||||
name: "Recraft: Recraft V4 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-fast": {
|
||||
id: "sourceful/riverflow-v2-fast",
|
||||
@@ -191,14 +236,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-fast-preview": {
|
||||
id: "sourceful/riverflow-v2-fast-preview",
|
||||
@@ -206,14 +251,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-max-preview": {
|
||||
id: "sourceful/riverflow-v2-max-preview",
|
||||
@@ -221,14 +266,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-pro": {
|
||||
id: "sourceful/riverflow-v2-pro",
|
||||
@@ -236,14 +281,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-standard-preview": {
|
||||
id: "sourceful/riverflow-v2-standard-preview",
|
||||
@@ -251,14 +296,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
input: ["text","image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
},
|
||||
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ vim ~/.pi/agent/themes/my-theme.json
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"name": "my-theme",
|
||||
"vars": {
|
||||
"primary": "#00aaff",
|
||||
@@ -122,7 +122,7 @@ vim ~/.pi/agent/themes/my-theme.json
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"name": "my-theme",
|
||||
"vars": {
|
||||
"blue": "#0066cc",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"name": "dynamic-resources",
|
||||
"vars": {
|
||||
"cyan": "#00d7ff",
|
||||
|
||||
@@ -876,6 +876,14 @@ export class AgentSession {
|
||||
return this.sessionManager.getSessionName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic counter emitted on each completed turn ({@link TurnEndEvent.turnIndex}); resets on agent_start.
|
||||
* Mirrors the value shown next to ✓ Turn N complete-style UI (N equals this counter after idle).
|
||||
*/
|
||||
get turnIndex(): number {
|
||||
return this._turnIndex;
|
||||
}
|
||||
|
||||
/** Scoped models for cycling (from --models flag) */
|
||||
get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {
|
||||
return this._scopedModels;
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as _bundledPiAi from "@earendil-works/pi-ai";
|
||||
import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth";
|
||||
import type { KeyId } from "@earendil-works/pi-tui";
|
||||
import * as _bundledPiTui from "@earendil-works/pi-tui";
|
||||
import { createJiti } from "jiti/static";
|
||||
import { createJiti } from "jiti";
|
||||
// Static imports of packages that extensions may use.
|
||||
// These MUST be static so Bun bundles them into the compiled binary.
|
||||
// The virtualModules option then makes them available to extensions.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"name": "dark",
|
||||
"vars": {
|
||||
"cyan": "#00d7ff",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"name": "light",
|
||||
"vars": {
|
||||
"teal": "#5a8080",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { type Theme, theme } from "../interactive/theme/theme.js";
|
||||
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
|
||||
import type {
|
||||
RpcCommand,
|
||||
RpcExtensionInfo,
|
||||
RpcExtensionUIRequest,
|
||||
RpcExtensionUIResponse,
|
||||
RpcResponse,
|
||||
@@ -442,10 +443,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
autoCompactionEnabled: session.autoCompactionEnabled,
|
||||
messageCount: session.messages.length,
|
||||
pendingMessageCount: session.pendingMessageCount,
|
||||
turnIndex: session.turnIndex,
|
||||
stats: session.getSessionStats(),
|
||||
};
|
||||
return success(id, "get_state", state);
|
||||
}
|
||||
|
||||
case "reload": {
|
||||
await session.reload();
|
||||
await rebindSession();
|
||||
return success(id, "reload");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Model
|
||||
// =================================================================
|
||||
@@ -561,7 +570,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
}
|
||||
|
||||
case "switch_session": {
|
||||
const result = await runtimeHost.switchSession(command.sessionPath);
|
||||
const result = await runtimeHost.switchSession(command.sessionPath, { cwdOverride: command.cwdOverride });
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
@@ -652,6 +661,26 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
return success(id, "get_commands", { commands });
|
||||
}
|
||||
|
||||
case "get_extensions": {
|
||||
const extensionsResult = session.resourceLoader.getExtensions();
|
||||
const extensions: RpcExtensionInfo[] = extensionsResult.extensions.map((extension) => {
|
||||
const name = extension.path.split(/[\\/]/).filter(Boolean).pop() || extension.path;
|
||||
return {
|
||||
name,
|
||||
path: extension.path,
|
||||
resolvedPath: extension.resolvedPath,
|
||||
sourceInfo: extension.sourceInfo,
|
||||
commands: Array.from(extension.commands.keys()).sort(),
|
||||
tools: Array.from(extension.tools.keys()).sort(),
|
||||
flags: Array.from(extension.flags.keys()).sort(),
|
||||
shortcuts: Array.from(extension.shortcuts.keys()).map(String).sort(),
|
||||
handlers: Array.from(extension.handlers.keys()).sort(),
|
||||
};
|
||||
});
|
||||
|
||||
return success(id, "get_extensions", { extensions, errors: extensionsResult.errors });
|
||||
}
|
||||
|
||||
default: {
|
||||
const unknownCommand = command as { type: string };
|
||||
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
|
||||
|
||||
@@ -26,6 +26,7 @@ export type RpcCommand =
|
||||
|
||||
// State
|
||||
| { id?: string; type: "get_state" }
|
||||
| { id?: string; type: "reload" }
|
||||
|
||||
// Model
|
||||
| { id?: string; type: "set_model"; provider: string; modelId: string }
|
||||
@@ -55,7 +56,7 @@ export type RpcCommand =
|
||||
// Session
|
||||
| { id?: string; type: "get_session_stats" }
|
||||
| { id?: string; type: "export_html"; outputPath?: string }
|
||||
| { id?: string; type: "switch_session"; sessionPath: string }
|
||||
| { id?: string; type: "switch_session"; sessionPath: string; cwdOverride?: string }
|
||||
| { id?: string; type: "fork"; entryId: string }
|
||||
| { id?: string; type: "clone" }
|
||||
| { id?: string; type: "get_fork_messages" }
|
||||
@@ -66,7 +67,8 @@ export type RpcCommand =
|
||||
| { id?: string; type: "get_messages" }
|
||||
|
||||
// Commands (available for invocation via prompt)
|
||||
| { id?: string; type: "get_commands" };
|
||||
| { id?: string; type: "get_commands" }
|
||||
| { id?: string; type: "get_extensions" };
|
||||
|
||||
// ============================================================================
|
||||
// RPC Slash Command (for get_commands response)
|
||||
@@ -84,6 +86,19 @@ export interface RpcSlashCommand {
|
||||
sourceInfo: SourceInfo;
|
||||
}
|
||||
|
||||
/** A loaded extension with basic inventory metadata */
|
||||
export interface RpcExtensionInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
resolvedPath: string;
|
||||
sourceInfo: SourceInfo;
|
||||
commands: string[];
|
||||
tools: string[];
|
||||
flags: string[];
|
||||
shortcuts: string[];
|
||||
handlers: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RPC State
|
||||
// ============================================================================
|
||||
@@ -101,6 +116,9 @@ export interface RpcSessionState {
|
||||
autoCompactionEnabled: boolean;
|
||||
messageCount: number;
|
||||
pendingMessageCount: number;
|
||||
/** Mirrors {@link AgentSession.turnIndex} (ticks every turn_end, resets agent_start). */
|
||||
turnIndex: number;
|
||||
stats: SessionStats;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -118,6 +136,7 @@ export type RpcResponse =
|
||||
|
||||
// State
|
||||
| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
|
||||
| { id?: string; type: "response"; command: "reload"; success: true }
|
||||
|
||||
// Model
|
||||
| {
|
||||
@@ -201,6 +220,13 @@ export type RpcResponse =
|
||||
success: true;
|
||||
data: { commands: RpcSlashCommand[] };
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_extensions";
|
||||
success: true;
|
||||
data: { extensions: RpcExtensionInfo[]; errors: Array<{ path: string; error: string }> };
|
||||
}
|
||||
|
||||
// Error response (any command can fail)
|
||||
| { id?: string; type: "response"; command: string; success: false; error: string };
|
||||
|
||||
@@ -331,6 +331,10 @@ export class Markdown implements Component {
|
||||
break;
|
||||
}
|
||||
|
||||
case "text":
|
||||
lines.push(this.renderInlineTokens([token], styleContext));
|
||||
break;
|
||||
|
||||
case "code": {
|
||||
const indent = this.theme.codeBlockIndent ?? " ";
|
||||
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
||||
@@ -354,7 +358,7 @@ export class Markdown implements Component {
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const listLines = this.renderList(token as any, 0, styleContext);
|
||||
const listLines = this.renderList(token as Tokens.List, 0, width, styleContext);
|
||||
lines.push(...listLines);
|
||||
// Don't add spacing after lists if a space token follows
|
||||
// (the space token will handle it)
|
||||
@@ -362,7 +366,7 @@ export class Markdown implements Component {
|
||||
}
|
||||
|
||||
case "table": {
|
||||
const tableLines = this.renderTable(token as any, width, nextTokenType, styleContext);
|
||||
const tableLines = this.renderTable(token as Tokens.Table, width, nextTokenType, styleContext);
|
||||
lines.push(...tableLines);
|
||||
break;
|
||||
}
|
||||
@@ -543,104 +547,39 @@ export class Markdown implements Component {
|
||||
/**
|
||||
* Render a list with proper nesting support
|
||||
*/
|
||||
private renderList(
|
||||
token: Token & { items: any[]; ordered: boolean; start?: number },
|
||||
depth: number,
|
||||
styleContext?: InlineStyleContext,
|
||||
): string[] {
|
||||
private renderList(token: Tokens.List, depth: number, width: number, styleContext?: InlineStyleContext): string[] {
|
||||
const lines: string[] = [];
|
||||
const indent = " ".repeat(depth);
|
||||
const indent = " ".repeat(depth);
|
||||
// Use the list's start property (defaults to 1 for ordered lists)
|
||||
const startNumber = token.start ?? 1;
|
||||
const startNumber = typeof token.start === "number" ? token.start : 1;
|
||||
|
||||
for (let i = 0; i < token.items.length; i++) {
|
||||
const item = token.items[i];
|
||||
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
|
||||
const firstPrefix = indent + this.theme.listBullet(bullet);
|
||||
const continuationPrefix = indent + " ".repeat(visibleWidth(bullet));
|
||||
const itemWidth = Math.max(1, width - visibleWidth(firstPrefix));
|
||||
let renderedAnyLine = false;
|
||||
|
||||
// Process item tokens to handle nested lists
|
||||
const itemLines = this.renderListItem(item.tokens || [], depth, styleContext);
|
||||
|
||||
if (itemLines.length > 0) {
|
||||
// First line - check if it's a nested list
|
||||
// A nested list will start with indent (spaces) followed by cyan bullet
|
||||
const firstLine = itemLines[0];
|
||||
const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char
|
||||
|
||||
if (isNestedList) {
|
||||
// This is a nested list, just add it as-is (already has full indent)
|
||||
lines.push(firstLine);
|
||||
} else {
|
||||
// Regular text content - add indent and bullet
|
||||
lines.push(indent + this.theme.listBullet(bullet) + firstLine);
|
||||
for (const itemToken of item.tokens) {
|
||||
if (itemToken.type === "list") {
|
||||
lines.push(...this.renderList(itemToken as Tokens.List, depth + 1, width, styleContext));
|
||||
renderedAnyLine = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rest of the lines
|
||||
for (let j = 1; j < itemLines.length; j++) {
|
||||
const line = itemLines[j];
|
||||
const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char
|
||||
|
||||
if (isNestedListLine) {
|
||||
// Nested list line - already has full indent
|
||||
lines.push(line);
|
||||
} else {
|
||||
// Regular content - add parent indent + 2 spaces for continuation
|
||||
lines.push(`${indent} ${line}`);
|
||||
const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext);
|
||||
for (const line of itemLines) {
|
||||
for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) {
|
||||
const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix;
|
||||
lines.push(linePrefix + wrappedLine);
|
||||
renderedAnyLine = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(indent + this.theme.listBullet(bullet));
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render list item tokens, handling nested lists
|
||||
* Returns lines WITHOUT the parent indent (renderList will add it)
|
||||
*/
|
||||
private renderListItem(tokens: Token[], parentDepth: number, styleContext?: InlineStyleContext): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === "list") {
|
||||
// Nested list - render with one additional indent level
|
||||
// These lines will have their own indent, so we just add them as-is
|
||||
const nestedLines = this.renderList(token as any, parentDepth + 1, styleContext);
|
||||
lines.push(...nestedLines);
|
||||
} else if (token.type === "text") {
|
||||
// Text content (may have inline tokens)
|
||||
const text =
|
||||
token.tokens && token.tokens.length > 0
|
||||
? this.renderInlineTokens(token.tokens, styleContext)
|
||||
: token.text || "";
|
||||
lines.push(text);
|
||||
} else if (token.type === "paragraph") {
|
||||
// Paragraph in list item
|
||||
const text = this.renderInlineTokens(token.tokens || [], styleContext);
|
||||
lines.push(text);
|
||||
} else if (token.type === "code") {
|
||||
// Code block in list item
|
||||
const indent = this.theme.codeBlockIndent ?? " ";
|
||||
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
||||
if (this.theme.highlightCode) {
|
||||
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
|
||||
for (const hlLine of highlightedLines) {
|
||||
lines.push(`${indent}${hlLine}`);
|
||||
}
|
||||
} else {
|
||||
const codeLines = token.text.split("\n");
|
||||
for (const codeLine of codeLines) {
|
||||
lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
|
||||
}
|
||||
}
|
||||
lines.push(this.theme.codeBlockBorder("```"));
|
||||
} else {
|
||||
// Other token types - try to render as inline
|
||||
const text = this.renderInlineTokens([token], styleContext);
|
||||
if (text) {
|
||||
lines.push(text);
|
||||
}
|
||||
if (!renderedAnyLine) {
|
||||
lines.push(firstPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +616,7 @@ export class Markdown implements Component {
|
||||
* Cells that don't fit are wrapped to multiple lines.
|
||||
*/
|
||||
private renderTable(
|
||||
token: Token & { header: any[]; rows: any[][]; raw?: string },
|
||||
token: Tokens.Table,
|
||||
availableWidth: number,
|
||||
nextTokenType?: string,
|
||||
styleContext?: InlineStyleContext,
|
||||
|
||||
@@ -31,8 +31,12 @@ function getCellUnderline(terminal: VirtualTerminal, row: number, col: number):
|
||||
return cell.isUnderline();
|
||||
}
|
||||
|
||||
function stripAnsi(line: string): string {
|
||||
return line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("Markdown component", () => {
|
||||
describe("Nested lists", () => {
|
||||
describe("Lists", () => {
|
||||
it("should render simple nested list", () => {
|
||||
const markdown = new Markdown(
|
||||
`- Item 1
|
||||
@@ -54,8 +58,8 @@ describe("Markdown component", () => {
|
||||
|
||||
// Check structure
|
||||
assert.ok(plainLines.some((line) => line.includes("- Item 1")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.1")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.1")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2")));
|
||||
assert.ok(plainLines.some((line) => line.includes("- Item 2")));
|
||||
});
|
||||
|
||||
@@ -75,9 +79,9 @@ describe("Markdown component", () => {
|
||||
|
||||
// Check proper indentation
|
||||
assert.ok(plainLines.some((line) => line.includes("- Level 1")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Level 2")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Level 3")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Level 4")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Level 2")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Level 3")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Level 4")));
|
||||
});
|
||||
|
||||
it("should render ordered nested list", () => {
|
||||
@@ -95,8 +99,8 @@ describe("Markdown component", () => {
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
|
||||
assert.ok(plainLines.some((line) => line.includes("1. First")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" 1. Nested first")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" 2. Nested second")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" 1. Nested first")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" 2. Nested second")));
|
||||
assert.ok(plainLines.some((line) => line.includes("2. Second")));
|
||||
});
|
||||
|
||||
@@ -116,7 +120,7 @@ describe("Markdown component", () => {
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
|
||||
assert.ok(plainLines.some((line) => line.includes("1. Ordered item")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Unordered nested")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Unordered nested")));
|
||||
assert.ok(plainLines.some((line) => line.includes("2. Second ordered")));
|
||||
});
|
||||
|
||||
@@ -156,6 +160,67 @@ describe("Markdown component", () => {
|
||||
assert.ok(numberedLines[1].startsWith("2."), `Second item should be "2.", got: ${numberedLines[1]}`);
|
||||
assert.ok(numberedLines[2].startsWith("3."), `Third item should be "3.", got: ${numberedLines[2]}`);
|
||||
});
|
||||
|
||||
it("should indent wrapped unordered list lines", () => {
|
||||
const markdown = new Markdown("- alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(20).map((line) => stripAnsi(line).trimEnd());
|
||||
|
||||
assert.deepStrictEqual(lines, ["- alpha beta gamma", " delta epsilon"]);
|
||||
});
|
||||
|
||||
it("should indent wrapped ordered list lines", () => {
|
||||
const markdown = new Markdown("1. alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(20).map((line) => stripAnsi(line).trimEnd());
|
||||
|
||||
assert.deepStrictEqual(lines, ["1. alpha beta gamma", " delta epsilon"]);
|
||||
});
|
||||
|
||||
it("should indent wrapped ordered list lines with multi-digit markers", () => {
|
||||
const markdown = new Markdown("10. alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(21).map((line) => stripAnsi(line).trimEnd());
|
||||
|
||||
assert.deepStrictEqual(lines, ["10. alpha beta gamma", " delta epsilon"]);
|
||||
});
|
||||
|
||||
it("should indent wrapped nested list lines", () => {
|
||||
const markdown = new Markdown(`- parent\n - alpha beta gamma delta epsilon`, 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd());
|
||||
|
||||
assert.deepStrictEqual(lines, ["- parent", " - alpha beta gamma", " delta epsilon"]);
|
||||
});
|
||||
|
||||
it("should indent wrapped nested list lines under ordered parents", () => {
|
||||
const markdown = new Markdown(`1. parent\n - alpha beta gamma delta epsilon`, 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd());
|
||||
|
||||
assert.deepStrictEqual(lines, ["1. parent", " - alpha beta gamma", " delta epsilon"]);
|
||||
});
|
||||
|
||||
it("should render and wrap blockquotes inside list items", () => {
|
||||
const markdown = new Markdown("- > alpha beta gamma delta epsilon zeta", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd());
|
||||
|
||||
assert.deepStrictEqual(lines, ["- │ alpha beta gamma", " │ delta epsilon zeta"]);
|
||||
});
|
||||
|
||||
it("should render and wrap code blocks inside list items", () => {
|
||||
const markdown = new Markdown(
|
||||
"- ```ts\n alpha beta gamma delta epsilon zeta\n ```",
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd());
|
||||
|
||||
assert.deepStrictEqual(lines, ["- ```ts", " alpha beta gamma", " delta epsilon zeta", " ```"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tables", () => {
|
||||
@@ -507,7 +572,7 @@ describe("Markdown component", () => {
|
||||
assert.ok(plainLines.some((line) => line.includes("Test Document")));
|
||||
// Check list
|
||||
assert.ok(plainLines.some((line) => line.includes("- Item 1")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Nested item")));
|
||||
assert.ok(plainLines.some((line) => line.includes(" - Nested item")));
|
||||
// Check table
|
||||
assert.ok(plainLines.some((line) => line.includes("Col1")));
|
||||
assert.ok(plainLines.some((line) => line.includes("│")));
|
||||
|
||||
@@ -3,6 +3,10 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Default to the repository-local pi config so this source checkout uses the
|
||||
# copied learning config instead of ~/.pi.
|
||||
export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$SCRIPT_DIR/.pi/agent}"
|
||||
|
||||
# Check for --no-env flag
|
||||
NO_ENV=false
|
||||
ARGS=()
|
||||
|
||||
Reference in New Issue
Block a user