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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Corollary — **extend, never duplicate.** Before writing anything that complete
| `/eval` | `producedFromToolEvents` (side-channel→`RuntimeEventLike` bridge) + `createTokenRecallChecker` | **re-exports** agent-eval's `verifyCompletion`/`extractProducedState`/`weightedComposite`/`createLlmCorrectnessChecker` |
| `/integrations` | hub `/exec` client + `resolveIntegrationAction` + `invokeIntegrationHub` (wiring) | peer-dep `@tangle-network/agent-integrations` (the engine/catalog) |
| `/interactions` | human-in-the-loop ask channel, both halves: the shared wire/persisted-part contract (`ChatInteraction`, part codecs, composer-as-answer routing, content-signature dedupe) + the server side — structural sidecar `/agents/sessions/{id}/interactions` client and `createInteractionAnswerRoute()` (list/answer endpoint factory: body validation, gone→410 mapping, duplicate-answer safety net, unblock verification, `resolveConnection` product seam) | peer `@tangle-network/agent-interface` (schema types only); connection is structural — no sandbox-SDK import. Server half must never reach client bundles (`tests/interactions-browser-safe.test.ts`) |
| `/chat-routes` | the assembled server chat vertical (#188 Phase 1): `createChatTurnRoutes()` — body parse/validate → injected `authorize` seam → producer seam → turn-buffer tap wired BY DEFAULT → NDJSON `Response` + replay endpoint + composed `/interactions` answer endpoints + `/chat-store` persistence (user row on send, assistant row with parts/usage on completion); `createSandboxChatProducer` (raw sandbox events → client vocabulary + persisted parts + usage receipt, non-renderable-ask auto-decline); `createUploadRoute` (multipart → inline `data:` part ≤700 KiB, else base64 write through a structural sandbox sink + path-ref part — the >1 MiB gateway cap makes the two-step mandatory); import-free `./wire` contract (`ChatTurnRequestPayload`, inline-part byte gate) re-exported via `/web-react`'s chat-stream | peer `@tangle-network/agent-runtime` (`handleChatTurn` IS the turn engine — zero loop logic here; subpath-only, not in the root barrel); composes `/stream`, `/chat-store`, `/interactions`, `/web`. Reference assembly: `examples/chat-app.md` |
| `/tangle` | app-registration consent URL + cached broker-token provider | structural `TangleAppsClient` (from agent-integrations) |
| `/billing` | per-workspace budget-capped key manager (mint/rotate/rollover/usage) | structural tcloud provisioner + store + crypto seams |
| `/missions` | durable multi-step mission orchestration: guarded status/step machine + cursor + cost ledger over a `MissionStorePort` (CAS updates → typed conflict, opaque `extras` insert passthrough for product columns), idempotent plan engine (cached-done short-circuit, cursor reconciliation, retryable-vs-deterministic failure, detached-session polling), budget/classification/volume gates that park as `waiting_approval`, `:::mission` parser, client-safe live-event reducer + the canonical `StepAgentActivity` per-step delegated-run lane (`step.updated` snapshot, latest-wins) | — (substrate-free; product supplies storage, `SandboxDispatch`, approvals port, `classifyStep`) |
Expand Down
159 changes: 159 additions & 0 deletions examples/chat-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# A multimodal chat app on the assembled vertical

The whole server chat vertical — auth, thread/message tables, streaming turn
with buffered replay, file uploads, sidecar question answering — assembled from
factories. No hand-rolled orchestration: the turn engine is agent-runtime's
`handleChatTurn`, durability is `/stream`'s turn buffer, persistence is
`/chat-store`, asks are `/interactions`. This file is the seed of the
`create-agent-app --chat` scaffold.

Who owns each hop:

| Hop | Owner |
| --- | --- |
| Session auth + guards | `/app-auth` (`createAppAuth`) |
| Thread/message tables + CRUD | `/chat-store` (`createChatTables` + `createChatStore`) |
| Body parse, turn identity, routes | `/chat-routes` (`createChatTurnRoutes`) |
| Turn engine (NDJSON protocol, hook order) | agent-runtime `handleChatTurn` |
| Sandbox events → client vocabulary + persisted parts | `/chat-routes` (`createSandboxChatProducer`) |
| Buffered replay after a drop | `/stream` turn buffer (wired by default) |
| Upload → `PromptInputPart` descriptors | `/chat-routes` (`createUploadRoute`) |
| Ask answering (list/answer, 410 mapping, dedupe) | `/interactions` via `routes.interactions` |
| Composer, stream consumption, cards | `/web-react` |

## Schema (drizzle + one migration constant)

```ts
// db/schema.ts
import { createChatTables } from '@tangle-network/agent-app/chat-store'
import { TURN_EVENTS_MIGRATION_SQL } from '@tangle-network/agent-app/stream' // append to migrations

export const { threads, messages } = createChatTables({ workspaceTable: workspaces })
```

## Server (one worker route file)

```ts
// server/chat.ts
import { createAppAuth } from '@tangle-network/agent-app/app-auth'
import {
createChatTurnRoutes, createSandboxChatProducer, createUploadRoute,
} from '@tangle-network/agent-app/chat-routes'
import { createChatStore } from '@tangle-network/agent-app/chat-store'
import { guardResolution } from '@tangle-network/agent-app/platform'
import { ensureWorkspaceSandbox, streamSandboxPrompt } from '@tangle-network/agent-app/sandbox'
import { createD1TurnEventStore } from '@tangle-network/agent-app/stream'
import { drizzle } from 'drizzle-orm/d1'
import { shell } from './sandbox-shell' // your SandboxRuntimeConfig (see build-agent-app)
import { messages, threads, users, sessions, accounts, verifications } from '../db/schema'

export function buildChat(env: Env) {
const db = drizzle(env.DB)
const { requireApiUser } = createAppAuth({
appName: 'Acme Agent', baseURL: env.BETTER_AUTH_URL, secret: env.BETTER_AUTH_SECRET,
db, schema: { users, sessions, accounts, verifications },
})

// The guard throws a JSON 401; guardResolution adapts it to {ok, response}.
const authorize = async ({ request }: { request: Request }) => {
const auth = await guardResolution(() => requireApiUser(request))
if (!auth.ok) return auth
const { user } = auth.value
return { ok: true as const, tenantId: user.id, userId: user.id, context: { user } }
}

const routes = createChatTurnRoutes({
projectId: 'acme-agent',
authorize,
store: createChatStore(db, { threads, messages }),
turnStore: createD1TurnEventStore(env.DB),
produce: async ({ prompt, body, identity, executionId }) => {
const box = await ensureWorkspaceSandbox(shell, {
workspaceId: identity.tenantId, userId: identity.userId, harness: 'opencode',
})
return createSandboxChatProducer({
model: body.model,
events: streamSandboxPrompt(shell, box, prompt, {
sessionId: identity.sessionId, executionId,
model: body.model, effort: body.effort,
interactions: { question: true, plan: true },
}),
})
},
interactions: {
resolveConnection: async ({ request, body }) => {
const auth = await authorize({ request })
if (!auth.ok) return auth
const threadId = String(body?.threadId ?? new URL(request.url).searchParams.get('threadId') ?? '')
const box = await ensureWorkspaceSandbox(shell, { workspaceId: auth.userId, harness: 'opencode' })
const c = box.connection
if (!c?.runtimeUrl) return { ok: false as const, unavailable: 'SANDBOX_UNAVAILABLE' }
// sessionId = the agent session the turn streams under (the thread id).
return { ok: true as const, connection: { runtimeUrl: c.runtimeUrl, authToken: c.authToken, sessionId: threadId } }
},
},
})

const upload = createUploadRoute({
authorize: async ({ request }) => {
const auth = await authorize({ request })
if (!auth.ok) return auth
const box = await ensureWorkspaceSandbox(shell, { workspaceId: auth.userId, harness: 'opencode' })
return { ok: true as const, sink: box.fs }
},
})

return { routes, upload }
}

// worker fetch handler
export async function handleChat(request: Request, env: Env, ctx: ExecutionContext) {
const { routes, upload } = buildChat(env)
const url = new URL(request.url)
if (url.pathname === '/api/chat' && request.method === 'POST') return routes.turn(request, ctx)
const replay = url.pathname.match(/^\/api\/chat\/replay\/([^/]+)$/)
if (replay) return routes.replay(request, { turnId: replay[1]! })
if (url.pathname === '/api/chat/upload') return upload(request)
if (url.pathname === '/api/chat/interactions' && request.method === 'GET') return routes.interactions!.list(request)
if (url.pathname === '/api/chat/interactions' && request.method === 'POST') return routes.interactions!.answer(request)
return new Response('Not found', { status: 404 })
}
```

## Client (composer → parts → stream → resume)

```tsx
// app/chat.tsx
import { ChatComposer, chatTurnRequestInit, streamChatTurn, type ComposerFile } from '@tangle-network/agent-app/web-react'

function Chat({ threadId }: { threadId: string }) {
const [files, setFiles] = useState<ComposerFile[]>([])

async function attach(list: FileList) {
const form = new FormData()
for (const f of Array.from(list)) form.append('files', f)
const res = await fetch('/api/chat/upload', { method: 'POST', body: form })
const { files: uploaded } = await res.json()
setFiles((prev) => [...prev, ...uploaded.map((u) => ({
id: u.id, name: u.name, size: u.size, kind: 'file' as const, status: 'ready' as const, part: u.part,
}))])
}

async function send(content: string, parts: ComposerFile['part'][]) {
setFiles([])
await streamChatTurn({
start: () => fetch('/api/chat', chatTurnRequestInit({ threadId, content, parts })),
resume: (turnId, fromSeq) => fetch(`/api/chat/replay/${turnId}?fromSeq=${fromSeq}`),
callbacks: { onText: appendDelta, onToolCall: showToolChip, onInteraction: showQuestionCard },
})
}

return <ChatComposer onSendParts={send} onAttach={attach} pendingFiles={files} onRemoveFile={(id) => setFiles((p) => p.filter((f) => f.id !== id))} />
}
```

Uploads ≤700 KiB come back as inline `data:` parts; bigger files are written
into the sandbox workspace and referenced by `path` (the ~1 MiB gateway body
cap makes that two-step mandatory). Question cards render with
`InteractionQuestionCard` + `useChatInteractions` and answer through
`/api/chat/interactions` — see `/web-react`.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@
"import": "./dist/chat-store/index.js",
"default": "./dist/chat-store/index.js"
},
"./chat-routes": {
"types": "./dist/chat-routes/index.d.ts",
"import": "./dist/chat-routes/index.js",
"default": "./dist/chat-routes/index.js"
},
"./crypto": {
"types": "./dist/crypto/index.d.ts",
"import": "./dist/crypto/index.js",
Expand Down
13 changes: 13 additions & 0 deletions src/chat-routes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* `/chat-routes` — the assembled server chat vertical (issue #188 Phase 1).
*
* Subpath-only (NOT re-exported from the root barrel): `turn-routes` imports
* the optional `@tangle-network/agent-runtime` peer at module top, same rule
* as `/app-auth`. The browser-safe wire contract lives in `./wire` and is
* re-exported through `/web-react`'s chat-stream glue.
*/

export * from './wire'
export * from './turn-routes'
export * from './sandbox-producer'
export * from './upload'
Loading