Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/playground/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# @any-harness/playground

Minimal reference harness on `@any-harness/metaharness` — e2e playground and
SDK-outreach demo. Runs one agent turn (or a REPL) through the AI SDK
`ToolLoopAgent` loop with capability injection:

- **storage** — `unstorage` fs driver at `.playground-store/`
- **secrets** — BYOK `SecretPort` over the injected env
- **bridge** — in-process demo host (`src/host.ts`) with canned extensions;
the `list_extensions` / `get_extension` / `call_extension_tool` /
`resolve_command` / `materialize_skill` tools read through it
- **exec** — computer-only slot, stubbed here (reports unavailability)

## Run

```bash
pnpm install
AI_BASE_URL=http://localhost:1234/v1 AI_MODEL_ID=<model> pnpm start
AI_BASE_URL=... AI_API_KEY=... AI_MODEL_ID=... pnpm start -- --prompt "list your extensions"
```

Any OpenAI-compatible endpoint works (OpenAI, Anthropic-compat gateways,
LM Studio, mocks). Try: "what extensions do you have?" — the model calls
`list_extensions` via bridge op `extensions.list`.
26 changes: 26 additions & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@any-harness/playground",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/cli.ts",
"build": "echo 'build: not yet implemented'",
"typecheck": "tsc --noEmit",
"lint": "echo 'lint: not yet implemented'",
"test": "vitest run",
"clean": "rm -rf dist .playground-store"
},
"dependencies": {
"@any-harness/metaharness": "workspace:*",
"@ai-sdk/openai-compatible": "^3.0.0",
"ai": "catalog:",
"unstorage": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
50 changes: 50 additions & 0 deletions apps/playground/src/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Capabilities, SecretPort, StoragePort } from "@any-harness/metaharness";
import { createStorage, type StorageValue } from "unstorage";
import fsDriver from "unstorage/drivers/fs";

import { demoBridge } from "./host.js";

const storagePort = (base: string): StoragePort => {
const storage = createStorage({ driver: fsDriver({ base }) });
return {
get: (key) => storage.getItem(key),
set: async (key, value) => {
await storage.setItem(key, value as StorageValue);
},
delete: async (key) => {
await storage.removeItem(key);
},
};
};

/** Env map → SecretPort. The host injects the map; nothing reads globals. */
const secretPort = (env: Record<string, string | undefined>): SecretPort => ({
resolve: (name) => env[name],
});

export interface PlaygroundCapabilitiesOptions {
/** Injected environment (e.g. `process.env`) for the BYOK secret slot. */
env: Record<string, string | undefined>;
/** Directory for the unstorage fs driver. */
storeDir: string;
}

export const createPlaygroundCapabilities = (
options: PlaygroundCapabilitiesOptions,
): Capabilities => ({
storage: storagePort(options.storeDir),
secrets: secretPort(options.env),
bridge: demoBridge(),
// Computer-only slot: the playground is a demo harness, so exec is a stub
// that reports unavailability rather than a real process runner. A computer
// build injects a real ExecPort; a browser build injects nothing and the
// same tools degrade cleanly.
exec: {
run: (command) =>
Promise.resolve({
code: 127,
stdout: "",
stderr: `exec stub: playground does not run commands (computer-only slot): ${command}`,
}),
},
});
96 changes: 96 additions & 0 deletions apps/playground/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Node edge of the playground — the only file allowed `node:*` imports.
// Everything upstream (capabilities, host, run loop) stays isomorphic.
import process from "node:process";
import readline from "node:readline/promises";

import type { ModelMessage } from "ai";

import { createPlaygroundCapabilities } from "./capabilities.js";
import { modelFromEnv } from "./model.js";
import { streamTurn } from "./run.js";

const USAGE = `anyharness playground — metaharness demo CLI

usage:
tsx src/cli.ts # REPL
tsx src/cli.ts --prompt "..." # one-shot turn
tsx src/cli.ts --help

env (BYOK): AI_BASE_URL, AI_API_KEY, AI_MODEL_ID — any OpenAI-compatible
endpoint (OpenAI, Anthropic-compat gateway, LM Studio, mock server).
`;

const promptArg = (argv: string[]): string | undefined => {
const i = argv.findIndex((a) => a === "--prompt" || a === "-p");
return i === -1 ? undefined : argv[i + 1];
};

const render = (event: Awaited<ReturnType<typeof streamTurn>> extends AsyncIterable<infer E> ? E : never): void => {
if (event.kind === "text") {
process.stdout.write(event.text);
} else if (event.kind === "tool-call") {
process.stdout.write(`\n[tool] ${event.tool} ${JSON.stringify(event.input)}\n`);
} else if (event.kind === "tool-result") {
const out = JSON.stringify(event.output);
process.stdout.write(`[result] ${out.length > 400 ? `${out.slice(0, 400)}…` : out}\n`);
} else if (event.kind === "finish") {
process.stdout.write("\n");
}
};

const main = async () => {
const argv = process.argv.slice(2);
if (argv.includes("--help") || argv.includes("-h")) {
process.stdout.write(USAGE);
return;
}

const resolved = modelFromEnv(process.env);
if ("error" in resolved) {
process.stderr.write(`${resolved.error}\n`);
process.exitCode = 2;
return;
}

const capabilities = createPlaygroundCapabilities({
env: process.env,
storeDir: ".playground-store",
});
const history: ModelMessage[] = [];
const once = promptArg(argv);

const runTurn = async (prompt: string) => {
history.push({ role: "user", content: prompt });
process.stdout.write("agent> ");
for await (const event of streamTurn({
model: resolved.model,
messages: history,
capabilities,
})) {
render(event);
}
};

if (once !== undefined) {
await runTurn(once);
return;
}

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
process.stdout.write("anyharness playground — type a message, 'exit' to quit\n");
for (;;) {
let line: string;
try {
line = await rl.question("you> ");
} catch {
break; // stdin closed (EOF / piped input ended)
}
const prompt = line.trim();
if (prompt === "exit" || prompt === "quit") break;
if (!prompt) continue;
await runTurn(prompt);
}
rl.close();
};

await main();
81 changes: 81 additions & 0 deletions apps/playground/src/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
inProcessBridge,
type BridgeClient,
type BridgeHandlers,
type Extension,
} from "@any-harness/metaharness";

/**
* Demo bridge host: an in-process binding over a canned extension set.
* Stands in for a real store host (`harness serve` over stdio) until W2/W3
* land the spec and the sdk implementation.
*/
const DEMO_EXTENSIONS: Extension[] = [
{
name: "commit-smith",
kind: "skill",
ref: { manifest: "~/.agents/skills/commit-smith/SKILL.md" },
description: "Draft conventional-commit messages from a staged diff.",
},
{
name: "echo-mcp",
kind: "mcp",
ref: { manifest: "~/.agents/harness/echo-mcp/plugin.json", version: "0.1.0" },
description: "Demo MCP server exposing a single `echo` tool.",
},
{
name: "review",
kind: "command",
ref: { manifest: "~/.agents/harness/review/plugin.json", version: "0.1.0" },
description: "Demo command: review the current diff.",
},
];

const COMMIT_SMITH_SKILL = `---
name: commit-smith
description: Draft conventional-commit messages from a staged diff.
---
# commit-smith
Read the staged diff, then propose a single conventional commit message
(type(scope): summary) plus an optional body listing breaking changes.
`;

const handlers: BridgeHandlers = {
"capabilities.negotiate": (params) => {
const requested = params?.capabilities ?? [];
// The demo host grants every declared slot — a real host denies the ones
// it can't serve (e.g. exec on browser targets).
return {
protocolVersion: params?.protocolVersion ?? "0.1",
granted: requested,
denied: [],
};
},
"extensions.list": (params) => ({
extensions: params?.kind
? DEMO_EXTENSIONS.filter((e) => e.kind === params.kind)
: DEMO_EXTENSIONS,
}),
"extensions.get": (params) => ({
extension: DEMO_EXTENSIONS.find((e) => e.name === params?.name) ?? null,
}),
"skills.materialize": (params) =>
params?.name === "commit-smith"
? { found: true, content: COMMIT_SMITH_SKILL }
: { found: false },
"commands.resolve": (params) =>
params?.name === "review"
? {
resolved: true,
instructions: "Review the current diff for correctness, then summarize findings.",
}
: { resolved: false },
"tools.call": (params) =>
params?.extension === "echo-mcp" && params?.tool === "echo"
? { output: params.input }
: { error: `unknown tool ${params?.extension}/${params?.tool}` },
"events.notify": () => ({ ok: true }),
// hooks.invoke intentionally omitted — the demo host has no hooks.
};

export const demoBridge = (): BridgeClient => inProcessBridge(handlers);
30 changes: 30 additions & 0 deletions apps/playground/src/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";

// Justified dep beyond the `ai` catalog: the playground needs one concrete
// provider adapter for BYOK demo runs; `ai` ships none. openai-compatible
// covers OpenAI, Anthropic-compat gateways and local servers (LM Studio,
// mocks) through a single env-driven config.
export interface ModelEnv {
AI_BASE_URL?: string;
AI_API_KEY?: string;
AI_MODEL_ID?: string;
}

export const modelFromEnv = (
env: ModelEnv,
): { model: LanguageModel } | { error: string } => {
if (!env.AI_BASE_URL || !env.AI_MODEL_ID) {
return {
error:
"set AI_BASE_URL + AI_MODEL_ID (and AI_API_KEY if the provider requires it) " +
"to run a real model",
};
}
const provider = createOpenAICompatible({
name: "playground-byok",
baseURL: env.AI_BASE_URL,
apiKey: env.AI_API_KEY,
});
return { model: provider.chatModel(env.AI_MODEL_ID) };
};
49 changes: 49 additions & 0 deletions apps/playground/src/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
aiSdkHarness,
extensionTools,
type Capabilities,
} from "@any-harness/metaharness";
import type { LanguageModel, ModelMessage } from "ai";

export type TurnEvent =
| { kind: "text"; text: string }
| { kind: "tool-call"; tool: string; input: unknown }
| { kind: "tool-result"; tool: string; output: unknown }
| { kind: "finish" };

export interface TurnInput {
model: LanguageModel;
messages: ModelMessage[];
capabilities: Capabilities;
signal?: AbortSignal;
}

/** One agent turn through the metaharness, normalized for CLI display. */
export async function* streamTurn(input: TurnInput): AsyncIterable<TurnEvent> {
const harness = aiSdkHarness();
for await (const part of harness.stream({
agent: {
name: "playground",
instructions:
"You are the AnyHarness playground agent. Your extension surfaces " +
"(list/get extensions, call extension tools, resolve commands, " +
"materialize skills, exec) come through injected capabilities — use " +
"them when asked; report plainly when a slot is unavailable.",
tools: extensionTools(),
},
model: input.model,
messages: input.messages,
capabilities: input.capabilities,
signal: input.signal,
})) {
if (part.type === "text-delta") {
yield { kind: "text", text: part.text };
} else if (part.type === "tool-call") {
yield { kind: "tool-call", tool: part.toolName, input: part.input };
} else if (part.type === "tool-result") {
yield { kind: "tool-result", tool: part.toolName, output: part.output };
} else if (part.type === "finish") {
yield { kind: "finish" };
}
}
}
9 changes: 9 additions & 0 deletions apps/playground/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*"]
}
Loading
Loading