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
9 changes: 9 additions & 0 deletions create-agent-app/README.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 9 additions & 1 deletion create-agent-app/package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand All @@ -12,13 +12,21 @@
],
"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"
},
"files": [
"index.mjs",
"template"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20"
}
Expand Down
8 changes: 6 additions & 2 deletions create-agent-app/template/_package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions create-agent-app/template/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import {
createOpenAICompatStreamTurn,
type LoopEvent,
type LoopMessage,
type LoopToolCall,
type ToolLoopEvent,
type ToolLoopResult,
} from '@tangle-network/agent-app/runtime'

interface ChatRequest {
Expand Down Expand Up @@ -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' } },
Expand Down
118 changes: 109 additions & 9 deletions tests/create-agent-app.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 })
Expand All @@ -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<string, string>
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
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.
Expand All @@ -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

Expand Down Expand Up @@ -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<string, string>
peerDependenciesMeta?: Record<string, { optional?: boolean }>
}
const gen = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as {
peerDependencies: Record<string, string>
devDependencies: Record<string, string>
}
// 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
Expand All @@ -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<string, string>
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')], {
Expand Down