diff --git a/create-agent-app/README.md b/create-agent-app/README.md new file mode 100644 index 0000000..cd4d36f --- /dev/null +++ b/create-agent-app/README.md @@ -0,0 +1,9 @@ +# @tangle-network/create-agent-app + +Scaffold a new Tangle agent product on [`@tangle-network/agent-app`](https://github.com/tangle-network/agent-app): + +```bash +npm create @tangle-network/agent-app@latest my-agent +``` + +Then `cd my-agent && pnpm install && pnpm typecheck && pnpm test`, and follow the generated `CUSTOMIZE.md` / `AGENTS.md`. diff --git a/create-agent-app/package.json b/create-agent-app/package.json index 6e92300..6a94785 100644 --- a/create-agent-app/package.json +++ b/create-agent-app/package.json @@ -1,5 +1,5 @@ { - "name": "create-agent-app", + "name": "@tangle-network/create-agent-app", "version": "0.1.0", "description": "Scaffold a new Tangle agent product on @tangle-network/agent-app: a filled agent.config skeleton, a wired chat route over the Cloudflare preset, an empty knowledge/ dir, and the agent-followable breadcrumb docs (AGENTS.md / CUSTOMIZE.md / KNOWLEDGE.md).", "keywords": [ @@ -12,6 +12,11 @@ ], "license": "MIT", "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/tangle-network/agent-app.git", + "directory": "create-agent-app" + }, "bin": { "create-agent-app": "./index.mjs" }, @@ -19,6 +24,9 @@ "index.mjs", "template" ], + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=20" } diff --git a/create-agent-app/template/_package.json b/create-agent-app/template/_package.json index 61d32d8..974c948 100644 --- a/create-agent-app/template/_package.json +++ b/create-agent-app/template/_package.json @@ -16,11 +16,15 @@ }, "peerDependencies": { "@tangle-network/agent-eval": ">=0.100.0", - "@tangle-network/agent-integrations": ">=0.32.0" + "@tangle-network/agent-integrations": ">=0.44.0", + "@tangle-network/agent-interface": ">=0.15.0", + "@tangle-network/agent-runtime": ">=0.79.3" }, "devDependencies": { "@tangle-network/agent-eval": "^0.100.0", - "@tangle-network/agent-integrations": "^0.32.0", + "@tangle-network/agent-integrations": "^0.44.0", + "@tangle-network/agent-interface": "^0.15.0", + "@tangle-network/agent-runtime": "^0.79.3", "@types/node": "^25.6.0", "typescript": "^5.7.0", "vitest": "^3.0.0", diff --git a/create-agent-app/template/src/worker.ts b/create-agent-app/template/src/worker.ts index 23d11ad..6048684 100644 --- a/create-agent-app/template/src/worker.ts +++ b/create-agent-app/template/src/worker.ts @@ -29,7 +29,9 @@ import { createOpenAICompatStreamTurn, type LoopEvent, type LoopMessage, + type LoopToolCall, type ToolLoopEvent, + type ToolLoopResult, } from '@tangle-network/agent-app/runtime' interface ChatRequest { @@ -107,14 +109,17 @@ export default { systemPrompt: buildSystemPrompt(), userMessage: body.message, streamTurn: narrowToToolLoopEvents(createOpenAICompatStreamTurn({ ...app.resolveModel(), tools })), - executeToolCall: (call) => executor({ toolName: call.toolName, args: call.args }), + executeToolCall: (call: LoopToolCall) => executor({ toolName: call.toolName, args: call.args }), isExecutableTool: isAppToolName, }) return new Response( JSON.stringify({ text: result.finalText, - toolResults: result.toolResults.map((t) => ({ label: t.label, outcome: t.outcome })), + toolResults: result.toolResults.map((t: ToolLoopResult['toolResults'][number]) => ({ + label: t.label, + outcome: t.outcome, + })), turns: result.turns, }), { headers: { 'Content-Type': 'application/json' } }, diff --git a/tests/create-agent-app.test.ts b/tests/create-agent-app.test.ts index 5e0ac6b..dd321d0 100644 --- a/tests/create-agent-app.test.ts +++ b/tests/create-agent-app.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll } from 'vitest' import { execFileSync } from 'node:child_process' -import { mkdtempSync, existsSync, mkdirSync, symlinkSync, readFileSync, realpathSync } from 'node:fs' +import { cpSync, mkdtempSync, existsSync, mkdirSync, symlinkSync, readFileSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -11,9 +11,8 @@ const CLI = join(REPO, 'create-agent-app', 'index.mjs') const DIST = join(REPO, 'dist') // Link the generated project's @tangle-network/* deps to this repo's real packages -// so `tsc` resolves the exact published types offline (no network install). The -// generated project depends on agent-app + the agent-eval/agent-integrations peers; -// all three already live in this repo's node_modules (agent-app IS this repo). +// so `tsc` + `vitest` resolve the exact published types/artifacts offline (no +// network install). function link(dest: string, src: string) { if (!existsSync(src)) return mkdirSync(join(dest, '..'), { recursive: true }) @@ -26,10 +25,37 @@ function linkDeps(projectDir: string) { const nm = join(projectDir, 'node_modules') const scope = join(nm, '@tangle-network') mkdirSync(scope, { recursive: true }) - // agent-app → repo root (has package.json `exports` + the built `dist`). - symlinkSync(REPO, join(scope, 'agent-app'), 'dir') - for (const peer of ['agent-eval', 'agent-integrations', 'agent-knowledge', 'agent-runtime']) { - link(join(scope, peer), join(REPO, 'node_modules', '@tangle-network', peer)) + // agent-app: COPY the published payload (package.json `exports` + built + // `dist`), do NOT symlink the repo root. A symlink lets Node walk up into the + // repo's own node_modules, silently resolving engine packages the template + // never declared — masking exactly the missing-peer bug class this suite must + // catch. The copy has no parent node_modules, like a real registry install. + const appDir = join(scope, 'agent-app') + mkdirSync(join(appDir, 'dist'), { recursive: true }) + cpSync(join(REPO, 'package.json'), join(appDir, 'package.json')) + cpSync(DIST, join(appDir, 'dist'), { recursive: true }) + // Engine packages: link exactly what the GENERATED package.json declares — no + // more. This mirrors a user's `pnpm install`: a dep the template forgot to + // declare stays unresolvable here and fails the typecheck/test below the same + // way it fails the user (that is how the missing agent-runtime peer shipped). + // A declared dep the repo cannot provide is template drift — fail loud. + const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as { + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + } + const declared = new Set( + [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies] + .flatMap((deps) => Object.keys(deps ?? {})) + .filter((name) => name.startsWith('@tangle-network/') && name !== '@tangle-network/agent-app'), + ) + for (const name of declared) { + const short = name.slice('@tangle-network/'.length) + const src = join(REPO, 'node_modules', '@tangle-network', short) + if (!existsSync(src)) { + throw new Error(`template declares ${name} but this repo has no installed copy to link`) + } + symlinkSync(realpathSync(src), join(scope, short), 'dir') } // tsc + its lib, node types, and vitest (a generated-project devDependency) — // resolved to their real pnpm paths so transitive types resolve offline. @@ -38,6 +64,20 @@ function linkDeps(projectDir: string) { link(join(nm, 'vitest'), join(REPO, 'node_modules', 'vitest')) } +// "1.2.3" (after stripping a `^`/`>=` prefix) → comparable numeric tuple. +function minVersion(range: string): number[] { + return range.replace(/^[~^>=\s]+/, '').split('.').map(Number) +} + +function versionGte(a: number[], b: number[]): boolean { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i] ?? 0 + const y = b[i] ?? 0 + if (x !== y) return x > y + } + return true +} + describe('create-agent-app scaffolder', () => { let projectDir: string @@ -86,6 +126,42 @@ describe('create-agent-app scaffolder', () => { expect(JSON.stringify(pkg)).not.toMatch(/__[A-Z_]+__/) }) + it('template engine pins match agent-app peerDependencies (drift gate)', () => { + const appPkg = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8')) as { + peerDependencies: Record + peerDependenciesMeta?: Record + } + const gen = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as { + peerDependencies: Record + devDependencies: Record + } + // Every engine peer the template pins must be pinned to agent-app's own range. + for (const [name, range] of Object.entries(gen.peerDependencies)) { + if (!name.startsWith('@tangle-network/')) continue + expect(appPkg.peerDependencies[name], `template pins ${name} but it is not an agent-app peer`).toBeTruthy() + expect(range, `template pins ${name}@${range}; agent-app wants ${appPkg.peerDependencies[name]}`).toBe( + appPkg.peerDependencies[name], + ) + } + // Every REQUIRED engine peer of agent-app must be declared by the template — + // an omission is exactly the class of bug that shipped (missing agent-runtime). + for (const [name, range] of Object.entries(appPkg.peerDependencies)) { + if (!name.startsWith('@tangle-network/')) continue + if (appPkg.peerDependenciesMeta?.[name]?.optional) continue + expect(gen.peerDependencies[name], `agent-app requires peer ${name}@${range}; the template omits it`).toBe(range) + } + // Each pinned engine peer must come with a devDependency that installs a + // version meeting the peer floor (otherwise `pnpm install` warns/underserves). + for (const [name, peerRange] of Object.entries(gen.peerDependencies)) { + const dev = gen.devDependencies[name] + expect(dev, `${name} has a peer pin but no devDependency to install it`).toBeTruthy() + expect( + versionGte(minVersion(dev as string), minVersion(peerRange)), + `${name} devDependency ${dev} is below the peer floor ${peerRange}`, + ).toBe(true) + } + }) + it('the generated skeleton typechecks against the real agent-app types', () => { // Run the project's own `tsc --noEmit` with its own tsconfig. This is the real // proof: agent.config.ts + the composer + the chat route resolve every @@ -102,7 +178,31 @@ describe('create-agent-app scaffolder', () => { output = (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '') throw new Error(`generated skeleton failed typecheck:\n${output}`) } - }) + }, 120_000) + + it("the generated skeleton's own test suite passes against the real agent-app dist", () => { + // Run the project's `vitest run` exactly as a user would. Unlike the + // typecheck (d.ts-level), this executes the dist chunks — so an engine + // package a chunk `import`s at runtime but the template forgot to declare + // (the shipped agent-runtime bug) fails here even when types resolve. + const vitestCli = join(projectDir, 'node_modules', 'vitest', 'vitest.mjs') + // Strip the parent runner's VITEST_* worker vars so the child starts clean. + const env = Object.fromEntries( + Object.entries(process.env).filter(([k]) => !k.startsWith('VITEST')), + ) as Record + try { + execFileSync('node', [vitestCli, 'run'], { + cwd: projectDir, + stdio: 'pipe', + env: { ...env, CI: 'true' }, + timeout: 90_000, + }) + } catch (err: unknown) { + const e = err as { stdout?: Buffer; stderr?: Buffer; message?: string } + const output = (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '') + throw new Error(`generated skeleton test suite failed:\n${output || e.message}`) + } + }, 120_000) it('knowledge:ingest runs (DRY) and reports the seeded knowledge dir', () => { const out = execFileSync('node', [join(projectDir, 'scripts', 'knowledge-ingest.mjs')], {