@tangle-network/create-agent-app 0.44.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/index.mjs +183 -0
- package/package.json +34 -0
- package/template/.dev.vars.example +11 -0
- package/template/AGENTS.md +67 -0
- package/template/CLAUDE.md +6 -0
- package/template/CUSTOMIZE.md +83 -0
- package/template/KNOWLEDGE.md +73 -0
- package/template/README.md +41 -0
- package/template/_gitignore +5 -0
- package/template/_package.json +33 -0
- package/template/_tsconfig.json +16 -0
- package/template/_wrangler.toml +22 -0
- package/template/agent.config.ts +113 -0
- package/template/knowledge/.gitkeep +0 -0
- package/template/knowledge/README.md +24 -0
- package/template/scripts/knowledge-ingest.mjs +86 -0
- package/template/src/agent-app.ts +77 -0
- package/template/src/worker.ts +128 -0
- package/template/tests/agent-app.test.ts +71 -0
- package/template/vitest.config.ts +7 -0
- package/template-chat/.dev.vars.example +18 -0
- package/template-chat/AGENTS.md +73 -0
- package/template-chat/CLAUDE.md +6 -0
- package/template-chat/CUSTOMIZE.md +88 -0
- package/template-chat/README.md +50 -0
- package/template-chat/_gitignore +6 -0
- package/template-chat/_package.json +38 -0
- package/template-chat/_tsconfig.json +16 -0
- package/template-chat/_wrangler.toml +38 -0
- package/template-chat/agent.config.ts +73 -0
- package/template-chat/declarations.d.ts +9 -0
- package/template-chat/migrations/0001_init.sql +109 -0
- package/template-chat/prompts/system.md +11 -0
- package/template-chat/public/index.html +236 -0
- package/template-chat/src/chat.ts +210 -0
- package/template-chat/src/db/schema.ts +70 -0
- package/template-chat/src/env.ts +33 -0
- package/template-chat/src/sandbox.ts +159 -0
- package/template-chat/src/worker.ts +58 -0
- package/template-chat/tests/chat-turn.e2e.test.ts +277 -0
- package/template-chat/vitest.config.ts +22 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent.config.ts — the DATA surface of this agent product.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONE file you fill to define who the agent is, what it may propose,
|
|
5
|
+
* what knowledge gates its loop, and which integrations it connects. It is plain
|
|
6
|
+
* data consumed by `@tangle-network/agent-app`'s modules through typed seams —
|
|
7
|
+
* NOT behavior. Do not put control flow here; that lives in `src/` (the chat
|
|
8
|
+
* route + composer). See CUSTOMIZE.md for the ordered fill-checklist and AGENTS.md
|
|
9
|
+
* for the layering contract.
|
|
10
|
+
*
|
|
11
|
+
* Every field below is stubbed with a placeholder. Replace the placeholders; keep
|
|
12
|
+
* the shape. `pnpm typecheck` proves the shape; `pnpm test` proves the wiring.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { defineAgentApp } from '@tangle-network/agent-app/config'
|
|
16
|
+
|
|
17
|
+
export const config = defineAgentApp({
|
|
18
|
+
// ① IDENTITY — who is the agent? (Discovery: "Whose job does this do, in whose
|
|
19
|
+
// voice, under what hard rules?")
|
|
20
|
+
identity: {
|
|
21
|
+
name: '__PROJECT_NAME__',
|
|
22
|
+
persona:
|
|
23
|
+
'You are an operations partner for <DOMAIN>. You automate the standing ' +
|
|
24
|
+
'workflows, stay grounded in real records, and route every regulated or ' +
|
|
25
|
+
'client-facing step to a named human for approval. Replace this paragraph ' +
|
|
26
|
+
'with the real persona — it is the spine of the system prompt.',
|
|
27
|
+
// Standing workflows, hard rules, tone — appended verbatim after the persona.
|
|
28
|
+
systemPromptFragments: [
|
|
29
|
+
'Never fabricate a figure (price, coverage, identifier, regulatory clause). ' +
|
|
30
|
+
'Cite a real record or say NOT ON FILE.',
|
|
31
|
+
],
|
|
32
|
+
// Named disclaimers the UI / chat pipeline can select by id.
|
|
33
|
+
disclaimers: {
|
|
34
|
+
'not-advice':
|
|
35
|
+
'This assistant prepares proposals for a licensed human to review and ' +
|
|
36
|
+
'approve. It does not itself give regulated advice or take regulated action.',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// ② TAXONOMY — what can the agent PROPOSE, and which proposals are regulated?
|
|
41
|
+
// (Discovery: "Which actions change client state or are legally gated, so a
|
|
42
|
+
// certified human must approve before they execute?")
|
|
43
|
+
// `regulatedTypes` MUST be a subset of `proposalTypes`. Regulated proposals
|
|
44
|
+
// CANNOT execute without a named certified approver — this is the
|
|
45
|
+
// human-in-the-loop invariant. Keep regulated steps regulated.
|
|
46
|
+
taxonomy: {
|
|
47
|
+
proposalTypes: ['recommend', 'contact', 'escalate'],
|
|
48
|
+
regulatedTypes: ['escalate'],
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// ③ KNOWLEDGE — what must the agent KNOW before it acts, and where does it learn?
|
|
52
|
+
// (Discovery: "What facts gate the loop — what's the minimum the agent must
|
|
53
|
+
// have grounded before it's allowed to propose?")
|
|
54
|
+
// `requirements` are declarative gates scored from workspace state by the
|
|
55
|
+
// Cloudflare preset's KnowledgeStateAccessor (config-set or rows-exist rules).
|
|
56
|
+
// `sources` are what the acquisition loop may read. `loop` tunes the gate.
|
|
57
|
+
knowledge: {
|
|
58
|
+
sources: [
|
|
59
|
+
// Domain docs you drop in ./knowledge are read as `vault://` sources.
|
|
60
|
+
{ uri: 'vault://knowledge', kind: 'vault' },
|
|
61
|
+
// Add web / regulation / integration sources the researcher may pull from.
|
|
62
|
+
// { uri: 'https://example.gov/regulation', kind: 'regulation' },
|
|
63
|
+
],
|
|
64
|
+
requirements: [
|
|
65
|
+
{
|
|
66
|
+
id: 'workspace-profile-set',
|
|
67
|
+
description: 'The workspace has a configured business profile.',
|
|
68
|
+
category: 'company_specific',
|
|
69
|
+
acquisitionMode: 'ask_user',
|
|
70
|
+
importance: 'blocking',
|
|
71
|
+
freshness: 'static',
|
|
72
|
+
// Satisfied when a config dot-path is set on the workspace.
|
|
73
|
+
satisfiedBy: { config: 'profile.businessName', nonEmpty: true },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'has-client-records',
|
|
77
|
+
description: 'At least one client/lead record exists to ground outreach.',
|
|
78
|
+
category: 'domain_specific',
|
|
79
|
+
acquisitionMode: 'query_connector',
|
|
80
|
+
importance: 'high',
|
|
81
|
+
freshness: 'daily',
|
|
82
|
+
// Satisfied when >= 1 row exists in a workspace-scoped table.
|
|
83
|
+
satisfiedBy: { table: 'knowledge', minRows: 1 },
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
loop: {
|
|
87
|
+
goal: 'Ground every client-facing claim against a real record before proposing.',
|
|
88
|
+
minConfidence: 0.7,
|
|
89
|
+
freshness: 'session',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// ⑤ INTEGRATIONS — what systems does the agent read/write through?
|
|
94
|
+
// (Discovery: "Which CRMs / data sources / messaging channels does the
|
|
95
|
+
// workflow touch?") These are @tangle-network/agent-integrations catalog
|
|
96
|
+
// kinds. Reads run immediately; writes return approval-required → a proposal.
|
|
97
|
+
integrations: {
|
|
98
|
+
enabled: [
|
|
99
|
+
// 'salesforce',
|
|
100
|
+
// 'whatsapp',
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// UI — may the agent emit generated views (render_ui)? Optional.
|
|
105
|
+
ui: {
|
|
106
|
+
generatedUi: true,
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// MODEL — omit to resolve from env (TANGLE_API_KEY) at boot via
|
|
110
|
+
// resolveTangleModelConfig. Pin here only if you need a fixed model.
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
export type Config = typeof config
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# knowledge/
|
|
2
|
+
|
|
3
|
+
Domain documents the agent grounds on. This directory is DATA — drop files here;
|
|
4
|
+
do not write code.
|
|
5
|
+
|
|
6
|
+
## Walk this
|
|
7
|
+
|
|
8
|
+
1. Drop your real domain docs in here as `.md` / `.txt` / `.json` (regulation,
|
|
9
|
+
product sheets, provider lists, playbooks). One topic per file. Subdirectories
|
|
10
|
+
are fine.
|
|
11
|
+
2. Register external research sources (URLs, regulation feeds, integration refs)
|
|
12
|
+
in `agent.config.ts` under `knowledge.sources` — those are what the acquisition
|
|
13
|
+
loop reads on top of these local files.
|
|
14
|
+
3. Run `pnpm knowledge:ingest` to enumerate inputs (DRY) and, once a model-backed
|
|
15
|
+
driver is wired, drive the acquisition loop (`--run`).
|
|
16
|
+
|
|
17
|
+
## What gates what
|
|
18
|
+
|
|
19
|
+
- The files here + `knowledge.sources` feed the BUILD loop (acquire grounded
|
|
20
|
+
knowledge). See KNOWLEDGE.md.
|
|
21
|
+
- `knowledge.requirements` in `agent.config.ts` is the ACT gate — what the agent
|
|
22
|
+
must KNOW before it's allowed to propose. Scored from live workspace state.
|
|
23
|
+
|
|
24
|
+
Do not commit secrets. Knowledge is content, not credentials.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// knowledge:ingest — the build-loop entry (NOT the act-gate; see KNOWLEDGE.md).
|
|
3
|
+
//
|
|
4
|
+
// This runs in Node (it touches the filesystem), never on the Worker edge path.
|
|
5
|
+
// It enumerates the domain docs you dropped under ./knowledge and the research
|
|
6
|
+
// sources declared in agent.config.ts, then drives the knowledge acquisition
|
|
7
|
+
// loop. By default it runs in DRY mode — it reports what WOULD be ingested so you
|
|
8
|
+
// can verify the inputs before spending model calls. Wire a real model-backed
|
|
9
|
+
// driver/decider (see the createKnowledgeLoop block below) and pass --run to
|
|
10
|
+
// execute the loop.
|
|
11
|
+
//
|
|
12
|
+
// Why a script and not a route: agent-knowledge owns disk I/O over a KB `root`;
|
|
13
|
+
// the acquisition loop proposes knowledge pages that a confidence gate accepts or
|
|
14
|
+
// drops. Grounding (sources) is always recorded; a low-confidence PROPOSAL is
|
|
15
|
+
// dropped — propose, don't apply. Tune the gate in KNOWLEDGE.md.
|
|
16
|
+
|
|
17
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
18
|
+
import { join, dirname } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { existsSync } from 'node:fs'
|
|
21
|
+
|
|
22
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
23
|
+
const KNOWLEDGE_DIR = join(ROOT, 'knowledge')
|
|
24
|
+
const RUN = process.argv.includes('--run')
|
|
25
|
+
|
|
26
|
+
async function listDocs(dir) {
|
|
27
|
+
if (!existsSync(dir)) return []
|
|
28
|
+
const out = []
|
|
29
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
30
|
+
if (entry.isDirectory()) out.push(...(await listDocs(join(dir, entry.name))))
|
|
31
|
+
else if (/\.(md|txt|json)$/i.test(entry.name)) out.push(join(dir, entry.name))
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Read agent.config.ts as text to discover declared sources without a TS runtime.
|
|
37
|
+
async function readDeclaredSources() {
|
|
38
|
+
const cfgPath = join(ROOT, 'agent.config.ts')
|
|
39
|
+
if (!existsSync(cfgPath)) return []
|
|
40
|
+
const text = await readFile(cfgPath, 'utf8')
|
|
41
|
+
// Pull `{ uri: '…', kind: '…' }` literals out of the knowledge.sources block.
|
|
42
|
+
const sources = []
|
|
43
|
+
const re = /\{\s*uri:\s*['"]([^'"]+)['"](?:\s*,\s*kind:\s*['"]([^'"]+)['"])?\s*\}/g
|
|
44
|
+
let m
|
|
45
|
+
while ((m = re.exec(text))) sources.push({ uri: m[1], kind: m[2] ?? 'unknown' })
|
|
46
|
+
return sources
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
const docs = await listDocs(KNOWLEDGE_DIR)
|
|
51
|
+
const sources = await readDeclaredSources()
|
|
52
|
+
|
|
53
|
+
console.log('knowledge:ingest')
|
|
54
|
+
console.log(` local docs (./knowledge): ${docs.length}`)
|
|
55
|
+
for (const d of docs) console.log(` - ${d.slice(ROOT.length + 1)}`)
|
|
56
|
+
console.log(` declared sources (agent.config.ts): ${sources.length}`)
|
|
57
|
+
for (const s of sources) console.log(` - [${s.kind}] ${s.uri}`)
|
|
58
|
+
|
|
59
|
+
if (!RUN) {
|
|
60
|
+
console.log('')
|
|
61
|
+
console.log('DRY run. Pass --run to drive the acquisition loop.')
|
|
62
|
+
console.log('Wire a model-backed driver + decider first — see the commented block in this file and KNOWLEDGE.md.')
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// To actually run the loop, install @tangle-network/agent-knowledge +
|
|
67
|
+
// @tangle-network/agent-runtime (peers), give the loop a KB `root` on disk and
|
|
68
|
+
// a model-backed driver, then:
|
|
69
|
+
//
|
|
70
|
+
// import { createKnowledgeLoop } from '@tangle-network/agent-app/knowledge-loop'
|
|
71
|
+
// import { config } from '../agent.config.ts' // via a TS loader / tsx
|
|
72
|
+
// const loop = createKnowledgeLoop(config.knowledge, {
|
|
73
|
+
// root: KNOWLEDGE_DIR,
|
|
74
|
+
// driver: async ({ systemPrompt, userMessage }) => ({ finalText: await callModel(systemPrompt, userMessage) }),
|
|
75
|
+
// defaultMinConfidence: config.knowledge.loop?.minConfidence ?? 0.7,
|
|
76
|
+
// })
|
|
77
|
+
// const result = await loop.run()
|
|
78
|
+
// console.log('applied:', result.applied)
|
|
79
|
+
//
|
|
80
|
+
throw new Error('--run requires a wired model-backed driver. See KNOWLEDGE.md before enabling.')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main().catch((err) => {
|
|
84
|
+
console.error(`knowledge:ingest: ${err.message}`)
|
|
85
|
+
process.exit(1)
|
|
86
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/agent-app.ts — the COMPOSER. Code, not data.
|
|
3
|
+
*
|
|
4
|
+
* Turns the declarative `agent.config.ts` + your Cloudflare bindings into the
|
|
5
|
+
* wired runtime surfaces:
|
|
6
|
+
* - tool handlers (the agent→app side channel) over the house D1 + KV preset,
|
|
7
|
+
* - the proposal taxonomy (from config.taxonomy — including the regulated set),
|
|
8
|
+
* - the knowledge gate accessor (scores config.knowledge.requirements from D1),
|
|
9
|
+
* - the resolved model config.
|
|
10
|
+
*
|
|
11
|
+
* This is the seam between DATA (config) and ENGINE (agent-app modules). You do
|
|
12
|
+
* NOT edit `@tangle-network/agent-app` to change behavior; you edit this file to
|
|
13
|
+
* compose its seams differently, or override a single handler. Default to the
|
|
14
|
+
* preset — drop down to a custom handler only when the house stack genuinely
|
|
15
|
+
* cannot express your persistence. See AGENTS.md "DATA vs CODE".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { config } from '../agent.config'
|
|
19
|
+
import {
|
|
20
|
+
createPresetToolHandlers,
|
|
21
|
+
createD1KnowledgeStateAccessor,
|
|
22
|
+
type D1Like,
|
|
23
|
+
type VaultKv,
|
|
24
|
+
} from '@tangle-network/agent-app/preset-cloudflare'
|
|
25
|
+
import { resolveTangleModelConfig } from '@tangle-network/agent-app/runtime'
|
|
26
|
+
import {
|
|
27
|
+
buildKnowledgeRequirements,
|
|
28
|
+
deriveSignals,
|
|
29
|
+
} from '@tangle-network/agent-app/knowledge'
|
|
30
|
+
import type { AppToolHandlers, AppToolTaxonomy } from '@tangle-network/agent-app/tools'
|
|
31
|
+
|
|
32
|
+
/** The Cloudflare bindings the worker hands the composer. */
|
|
33
|
+
export interface AppBindings {
|
|
34
|
+
/** D1 database — satisfies the preset's structural {@link D1Like}. */
|
|
35
|
+
DB: D1Like
|
|
36
|
+
/** KV namespace used as the artifact vault — satisfies {@link VaultKv}. */
|
|
37
|
+
VAULT: VaultKv
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ComposedAgentApp {
|
|
41
|
+
handlers: AppToolHandlers
|
|
42
|
+
taxonomy: AppToolTaxonomy
|
|
43
|
+
/** Score the config's knowledge requirements against live workspace state. */
|
|
44
|
+
knowledgeGate: (workspaceId: string) => Promise<ReturnType<typeof buildKnowledgeRequirements>>
|
|
45
|
+
/** Resolve the model config from env. Lazy + fail-loud: composing the app does
|
|
46
|
+
* NOT require model env; only the chat path that actually streams does. */
|
|
47
|
+
resolveModel: () => ReturnType<typeof resolveTangleModelConfig>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compose the agent app from config + bindings. No domain value is hard-coded:
|
|
52
|
+
* the taxonomy comes from `config.taxonomy`, the gate from
|
|
53
|
+
* `config.knowledge.requirements`. Swap a handler here if (and only if) the
|
|
54
|
+
* preset cannot express your persistence.
|
|
55
|
+
*/
|
|
56
|
+
export function createAgentApp(bindings: AppBindings): ComposedAgentApp {
|
|
57
|
+
const handlers = createPresetToolHandlers({ db: bindings.DB, vault: bindings.VAULT })
|
|
58
|
+
|
|
59
|
+
const taxonomy: AppToolTaxonomy = {
|
|
60
|
+
proposalTypes: config.taxonomy.proposalTypes,
|
|
61
|
+
regulatedTypes: config.taxonomy.regulatedTypes,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function knowledgeGate(workspaceId: string) {
|
|
65
|
+
const accessor = createD1KnowledgeStateAccessor({
|
|
66
|
+
db: bindings.DB,
|
|
67
|
+
workspaceId,
|
|
68
|
+
// Resolve workspace config however your app stores it; stub returns nothing
|
|
69
|
+
// until you wire real workspace config (see CUSTOMIZE.md ③).
|
|
70
|
+
config: () => undefined,
|
|
71
|
+
})
|
|
72
|
+
const signals = await deriveSignals(config.knowledge.requirements, accessor)
|
|
73
|
+
return buildKnowledgeRequirements(config.knowledge.requirements, signals)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { handlers, taxonomy, knowledgeGate, resolveModel: () => resolveTangleModelConfig() }
|
|
77
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/worker.ts — the chat route. Code, not data.
|
|
3
|
+
*
|
|
4
|
+
* The minimal wired entry: a Cloudflare Worker that runs one bounded tool-loop
|
|
5
|
+
* turn per chat request, with the agent→app tool side channel wired to the
|
|
6
|
+
* preset handlers and the OpenAI-compatible Tangle Router as the backend.
|
|
7
|
+
*
|
|
8
|
+
* What this proves: the agent can call `submit_proposal` / `schedule_followup` /
|
|
9
|
+
* `render_ui` / `add_citation`, each call is validated against your
|
|
10
|
+
* `config.taxonomy` and persisted by the preset — with regulated proposals
|
|
11
|
+
* fail-closed to the approval queue (never auto-executed). You extend the route;
|
|
12
|
+
* you do not edit `@tangle-network/agent-app`.
|
|
13
|
+
*
|
|
14
|
+
* Replace the system-prompt assembly + per-turn context recovery with your real
|
|
15
|
+
* auth/session once you have one. Keep the human-in-the-loop invariant: regulated
|
|
16
|
+
* proposals stay proposals.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { config } from '../agent.config'
|
|
20
|
+
import { createAgentApp, type AppBindings } from './agent-app'
|
|
21
|
+
import {
|
|
22
|
+
buildAppToolOpenAITools,
|
|
23
|
+
createAppToolRuntimeExecutor,
|
|
24
|
+
isAppToolName,
|
|
25
|
+
type AppToolContext,
|
|
26
|
+
} from '@tangle-network/agent-app/tools'
|
|
27
|
+
import {
|
|
28
|
+
runAppToolLoop,
|
|
29
|
+
createOpenAICompatStreamTurn,
|
|
30
|
+
type LoopEvent,
|
|
31
|
+
type LoopMessage,
|
|
32
|
+
type LoopToolCall,
|
|
33
|
+
type ToolLoopEvent,
|
|
34
|
+
type ToolLoopResult,
|
|
35
|
+
} from '@tangle-network/agent-app/runtime'
|
|
36
|
+
|
|
37
|
+
interface ChatRequest {
|
|
38
|
+
message: string
|
|
39
|
+
/** Trusted server-side in a real app; taken from the body here for the skeleton. */
|
|
40
|
+
userId?: string
|
|
41
|
+
workspaceId?: string
|
|
42
|
+
threadId?: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The OpenAI-compat stream yields the app's rich `LoopEvent` (it adds UI-only
|
|
47
|
+
* `reasoning` / `usage` deltas). The awaitable loop consumes only `text` +
|
|
48
|
+
* `tool_call`, so narrow the rest onto the substrate's `other` channel. (The
|
|
49
|
+
* streaming `streamAppToolLoop` path takes the rich stream directly via
|
|
50
|
+
* `extractText` / `extractToolCall` and needs no narrowing.)
|
|
51
|
+
*/
|
|
52
|
+
function narrowToToolLoopEvents(
|
|
53
|
+
streamTurn: (messages: LoopMessage[]) => AsyncIterable<LoopEvent>,
|
|
54
|
+
): (messages: LoopMessage[]) => AsyncIterable<ToolLoopEvent> {
|
|
55
|
+
return (messages) =>
|
|
56
|
+
(async function* () {
|
|
57
|
+
for await (const ev of streamTurn(messages)) {
|
|
58
|
+
if (ev.type === 'text') yield { type: 'text', text: ev.text }
|
|
59
|
+
else if (ev.type === 'tool_call') yield { type: 'tool_call', call: ev.call }
|
|
60
|
+
else yield { type: 'other', event: ev }
|
|
61
|
+
}
|
|
62
|
+
})()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Assemble the system prompt from the config identity (DATA → prompt). */
|
|
66
|
+
function buildSystemPrompt(): string {
|
|
67
|
+
const fragments = [
|
|
68
|
+
config.identity.persona,
|
|
69
|
+
...(config.identity.systemPromptFragments ?? []),
|
|
70
|
+
...Object.values(config.identity.disclaimers ?? {}),
|
|
71
|
+
]
|
|
72
|
+
return fragments.join('\n\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default {
|
|
76
|
+
async fetch(request: Request, env: AppBindings): Promise<Response> {
|
|
77
|
+
const url = new URL(request.url)
|
|
78
|
+
if (url.pathname !== '/chat' || request.method !== 'POST') {
|
|
79
|
+
return new Response('Not found', { status: 404 })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const body = (await request.json()) as ChatRequest
|
|
83
|
+
if (!body.message) {
|
|
84
|
+
return new Response(JSON.stringify({ error: 'message is required' }), {
|
|
85
|
+
status: 400,
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const app = createAgentApp(env)
|
|
91
|
+
|
|
92
|
+
// Trusted per-turn context. In production recover this from your auth/session,
|
|
93
|
+
// NEVER from model tool args — the model must not be able to forge identity.
|
|
94
|
+
const ctx: AppToolContext = {
|
|
95
|
+
userId: body.userId ?? 'anonymous',
|
|
96
|
+
workspaceId: body.workspaceId ?? 'default',
|
|
97
|
+
threadId: body.threadId ?? null,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const executor = createAppToolRuntimeExecutor({
|
|
101
|
+
handlers: app.handlers,
|
|
102
|
+
taxonomy: app.taxonomy,
|
|
103
|
+
ctx,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const tools = buildAppToolOpenAITools(app.taxonomy)
|
|
107
|
+
|
|
108
|
+
const result = await runAppToolLoop({
|
|
109
|
+
systemPrompt: buildSystemPrompt(),
|
|
110
|
+
userMessage: body.message,
|
|
111
|
+
streamTurn: narrowToToolLoopEvents(createOpenAICompatStreamTurn({ ...app.resolveModel(), tools })),
|
|
112
|
+
executeToolCall: (call: LoopToolCall) => executor({ toolName: call.toolName, args: call.args }),
|
|
113
|
+
isExecutableTool: isAppToolName,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return new Response(
|
|
117
|
+
JSON.stringify({
|
|
118
|
+
text: result.finalText,
|
|
119
|
+
toolResults: result.toolResults.map((t: ToolLoopResult['toolResults'][number]) => ({
|
|
120
|
+
label: t.label,
|
|
121
|
+
outcome: t.outcome,
|
|
122
|
+
})),
|
|
123
|
+
turns: result.turns,
|
|
124
|
+
}),
|
|
125
|
+
{ headers: { 'Content-Type': 'application/json' } },
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { config } from '../agent.config'
|
|
3
|
+
import { createAgentApp } from '../src/agent-app'
|
|
4
|
+
import type { D1Like, VaultKv } from '@tangle-network/agent-app/preset-cloudflare'
|
|
5
|
+
|
|
6
|
+
// Tiny in-memory fakes for D1 + KV so the composer runs without Cloudflare.
|
|
7
|
+
function fakeDb(): D1Like {
|
|
8
|
+
return {
|
|
9
|
+
prepare() {
|
|
10
|
+
return {
|
|
11
|
+
bind() {
|
|
12
|
+
return this
|
|
13
|
+
},
|
|
14
|
+
async first<T>() {
|
|
15
|
+
return null as T | null
|
|
16
|
+
},
|
|
17
|
+
async run() {
|
|
18
|
+
return { success: true }
|
|
19
|
+
},
|
|
20
|
+
async all<T>() {
|
|
21
|
+
return { results: [] as T[] }
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
} as unknown as D1Like
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fakeVault(): VaultKv {
|
|
29
|
+
const store = new Map<string, string>()
|
|
30
|
+
return {
|
|
31
|
+
async get(key: string) {
|
|
32
|
+
return store.get(key) ?? null
|
|
33
|
+
},
|
|
34
|
+
async put(key: string, value: string) {
|
|
35
|
+
store.set(key, value)
|
|
36
|
+
},
|
|
37
|
+
} as unknown as VaultKv
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('agent.config', () => {
|
|
41
|
+
it('regulatedTypes is a subset of proposalTypes (human-in-the-loop invariant)', () => {
|
|
42
|
+
for (const t of config.taxonomy.regulatedTypes) {
|
|
43
|
+
expect(config.taxonomy.proposalTypes).toContain(t)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('has a non-empty identity persona', () => {
|
|
48
|
+
expect(config.identity.persona.length).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('createAgentApp', () => {
|
|
53
|
+
it('composes handlers + taxonomy from config without hard-coded domain values', () => {
|
|
54
|
+
const app = createAgentApp({ DB: fakeDb(), VAULT: fakeVault() })
|
|
55
|
+
expect(app.taxonomy.proposalTypes).toEqual(config.taxonomy.proposalTypes)
|
|
56
|
+
expect(typeof app.handlers.submitProposal).toBe('function')
|
|
57
|
+
expect(typeof app.knowledgeGate).toBe('function')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('a submitted proposal is queued (regulated never auto-executes)', async () => {
|
|
61
|
+
const app = createAgentApp({ DB: fakeDb(), VAULT: fakeVault() })
|
|
62
|
+
const regulated = config.taxonomy.regulatedTypes[0]
|
|
63
|
+
if (!regulated) return
|
|
64
|
+
const r = await app.handlers.submitProposal(
|
|
65
|
+
{ type: regulated, title: 'test', description: null },
|
|
66
|
+
{ userId: 'u', workspaceId: 'w', threadId: null },
|
|
67
|
+
)
|
|
68
|
+
// The preset returns a pending proposal id; it is NOT an executed side effect.
|
|
69
|
+
expect(typeof r.proposalId).toBe('string')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copy to .dev.vars for local development (never commit .dev.vars).
|
|
2
|
+
# Production: `wrangler secret put <NAME>` for each secret.
|
|
3
|
+
|
|
4
|
+
# better-auth HMAC secret — generate one: openssl rand -base64 32
|
|
5
|
+
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET
|
|
6
|
+
|
|
7
|
+
# Tangle Router key the harness bills model calls against.
|
|
8
|
+
TANGLE_API_KEY=
|
|
9
|
+
# Optional router override; omit for the platform default.
|
|
10
|
+
# TANGLE_ROUTER_URL=
|
|
11
|
+
|
|
12
|
+
# Sandbox gateway credentials — the agent runs in a Tangle sandbox. Without
|
|
13
|
+
# these every turn fails loud (there is no mock agent).
|
|
14
|
+
SANDBOX_API_KEY=
|
|
15
|
+
SANDBOX_GATEWAY_URL=
|
|
16
|
+
|
|
17
|
+
# Model override without a redeploy (falls back to config.model.default).
|
|
18
|
+
# MODEL_NAME=
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# AGENTS.md — you are customizing a chat agent-app
|
|
2
|
+
|
|
3
|
+
You are a coding agent working in a project generated by `create-agent-app --chat`.
|
|
4
|
+
This project is a thin customization layer on top of `@tangle-network/agent-app`
|
|
5
|
+
(the shell): the whole server chat vertical — auth, thread/message persistence,
|
|
6
|
+
streaming turns with buffered replay, multimodal uploads, human-in-the-loop asks —
|
|
7
|
+
is ASSEMBLED from shell factories, not written here. Walk this contract before you
|
|
8
|
+
touch anything. It is a checklist, not prose — follow it in order.
|
|
9
|
+
|
|
10
|
+
## 0. Orient
|
|
11
|
+
|
|
12
|
+
- [ ] Read this file end to end.
|
|
13
|
+
- [ ] Read `CUSTOMIZE.md` — the ordered fill-checklist. It is your task list.
|
|
14
|
+
- [ ] Run `pnpm install && pnpm typecheck && pnpm test`. Confirm green BEFORE editing.
|
|
15
|
+
The test suite includes the end-to-end turn gate (fake sandbox producer →
|
|
16
|
+
streamed turn → persisted transcript) — it proves the assembly, not stubs.
|
|
17
|
+
|
|
18
|
+
## 1. The one rule (layering)
|
|
19
|
+
|
|
20
|
+
> You change behavior by editing DATA and the COMPOSER — never by editing
|
|
21
|
+
> `@tangle-network/agent-app`.
|
|
22
|
+
|
|
23
|
+
- The shell owns mechanism: turn protocol + hook order (`createChatTurnRoutes`
|
|
24
|
+
over agent-runtime's `handleChatTurn`), persistence columns (`createChatTables`),
|
|
25
|
+
the upload inline-vs-sandbox split, the interaction answer contract, auth guards.
|
|
26
|
+
- You own: `agent.config.ts` + `prompts/system.md` (DATA), `src/` (the COMPOSER +
|
|
27
|
+
routes), `migrations/` (must mirror the schema — the e2e test executes it),
|
|
28
|
+
`public/` (the dev page). That is the whole surface.
|
|
29
|
+
|
|
30
|
+
## 2. DATA vs CODE — know which file you're in
|
|
31
|
+
|
|
32
|
+
- [ ] DATA → `agent.config.ts`: name, system prompt, model default + effort,
|
|
33
|
+
harness, renderable ask kinds. Plain values. If you're writing an `if`,
|
|
34
|
+
you're in the wrong file.
|
|
35
|
+
- [ ] DATA → `prompts/system.md`: the persona. State intents and hard rules,
|
|
36
|
+
never implementations — no shell commands, CLI flags, or install scripts.
|
|
37
|
+
The executing agent chooses tools at execution time.
|
|
38
|
+
- [ ] CODE → `src/chat.ts`: the COMPOSER. Wires config + env into the shell's
|
|
39
|
+
factories. Extend seams here (billing hooks, `transformFinalText`,
|
|
40
|
+
`onTurnComplete`); never re-implement what a factory already does.
|
|
41
|
+
- [ ] CODE → `src/sandbox.ts`: the sandbox lane (box naming, credentials,
|
|
42
|
+
profile). All agent intelligence lives IN the sandbox; this file only
|
|
43
|
+
reaches it.
|
|
44
|
+
- [ ] CODE → `src/worker.ts`: routing only. New endpoint = new handler in
|
|
45
|
+
`src/chat.ts`, one `if` here.
|
|
46
|
+
|
|
47
|
+
## 3. Invariants — fail-closed, never relax
|
|
48
|
+
|
|
49
|
+
- [ ] TRUSTED CONTEXT: `userId`/`workspaceId` come from the better-auth SESSION,
|
|
50
|
+
never from a request body or model output. The `authorize` seam is the only
|
|
51
|
+
place identity is established — keep it that way.
|
|
52
|
+
- [ ] THREAD ACCESS: an inaccessible thread reads as 404, indistinguishable from
|
|
53
|
+
a missing one. A cross-workspace probe must not learn the thread exists.
|
|
54
|
+
- [ ] NO MOCK AGENT: without sandbox credentials a turn fails loud with a clear
|
|
55
|
+
error. Never add a canned-response fallback — a fake answer is worse than
|
|
56
|
+
an honest failure.
|
|
57
|
+
- [ ] AGENT-NATIVE: intelligence and tooling live in the sandboxed agent;
|
|
58
|
+
durability (rows, turn buffer) and money live here. If you're about to
|
|
59
|
+
parse the agent's prose for data, stop — that's a schema-validated tool's
|
|
60
|
+
job (see the shell's `/tools`).
|
|
61
|
+
- [ ] GROUNDING: the persona's fabrication rule stays. A real record or an
|
|
62
|
+
explicit "NOT ON FILE".
|
|
63
|
+
|
|
64
|
+
## 4. Verify (every change ends here)
|
|
65
|
+
|
|
66
|
+
- [ ] `pnpm typecheck` — clean. Proves the assembly matches the shell contract.
|
|
67
|
+
- [ ] `pnpm test` — green. The e2e gate runs the REAL migration + REAL factories
|
|
68
|
+
with one fake (the sandbox event feed). If it fails, the app drifted from
|
|
69
|
+
the framework — fix the drift, not the test.
|
|
70
|
+
- [ ] For a real deploy: fill `wrangler.toml` + `.dev.vars`, run
|
|
71
|
+
`pnpm db:migrate:local`, then `pnpm dev` and exercise the dev page.
|
|
72
|
+
|
|
73
|
+
If any of these is red, you are not done. Do not weaken a test to pass.
|