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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ One encrypted authorization attempt per connection lives in the existing Postgre

Refresh claims a timestamp inside the encrypted credential envelope against the current credential ciphertext and lock version. Token exchange runs outside transactions. Conditional commits prevent stale work from replacing new credentials or undoing disconnect. A rotating response without a replacement refresh token preserves the current grant's refresh token. An expired claim or ambiguous response requires reconnection, without redeeming the old token again. Google authorization requests consent and offline access. Disconnect is local and does not revoke provider-wide grants. Tokens never enter workflow journals or idempotency receipts.

Deterministic fixtures cover protocol, ownership, OAuth and cross-process refresh. Live provider verification is separate. Legacy MCP revisions, stdio, protocol sessions, private networks, subscriptions, resources, prompts, sampling, elicitation and incremental scope authorization are outside this client. The package's modern tool path does not mirror `x-mcp-header` parameters and ignores `structuredContent` without a declared output schema. Organizations configure required OAuth scopes before connecting. Discovery has response-size, page, time and connection bounds. All discovered tools are exposed without a separate catalog allocator, so the model provider's own limits apply to the combined tool set.
Deterministic fixtures cover protocol, ownership, OAuth and cross-process refresh. Live provider verification is separate. Legacy MCP revisions, stdio, protocol sessions, private networks, subscriptions, resources, prompts, sampling, elicitation and incremental scope authorization are outside this client. The SDK mirrors `x-mcp-header` parameters, while AstralBeam filters invalid declarations and rejects unsafe mirrored values before dispatch. The package ignores `structuredContent` without a declared output schema. Organizations configure required OAuth scopes before connecting. Discovery has response-size, page, time and connection bounds. All discovered tools are exposed without a separate catalog allocator, so the model provider's own limits apply to the combined tool set.

To verify live providers, open **MCP connections** in the embedded sidebar, connect to a server, and choose **Test connection**. This lists tools without invoking business tools. See [MCP servers](platform/src/routes/docs/-content/dashboard/connections.md) for hosted examples.

Expand Down Expand Up @@ -167,7 +167,7 @@ Execution remains attached to its foreground request. Process crashes can leave

Sandbox identity includes the configured provider connection and authenticated principal. Each process reserves at most 25 leases before provisioning, protects active runs, and expires workspaces 15 minutes after the final run closes. Reported deletion failures retain their lease for retry. Cleanup remains best effort: abrupt process termination loses these records, and the installed Docker, Daytona, and Vercel adapters suppress deletion errors. Guaranteed eventual cleanup would require provider expiry or external reconciliation.

Sandbox files and command output are buffered before size checks or truncation, and wrapper timeouts may not stop transfers. Sparse XLSX worksheets are padded and rendered synchronously before text truncation, without a document-wide work budget. Both paths can exhaust replica resources.
Known regular-file artifact sizes are checked before transfer and bytes are checked again before publication. File changes can race that metadata check. Other sandbox reads and command output are buffered before truncation, and wrapper timeouts may not stop transfers. Sparse XLSX worksheets are padded and rendered synchronously before text truncation, without a document-wide work budget. Both paths can exhaust replica resources.

Management APIs lack application body, name, and metadata budgets. Their schemas can accept NUL or ill-formed Unicode that PostgreSQL rejects as internal failures. Ingress limits and client-address forwarding require hosted verification.

Expand Down
90 changes: 38 additions & 52 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions examples/todos/e2e/pages/chat-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,10 @@ export function chatWidget(page: Page) {
await test.step(`send "${text}"`, async () => {
await this.waitForReady()
await composer.fill(text)
await composer.press("Enter")
await expect(root.getByText(text, { exact: false }).first()).toBeVisible()
await sendButton.click()
await expect(
root.locator('[data-slot="message"][data-align="end"]').getByText(text, { exact: true }),
).toBeVisible()
})
},

Expand Down
26 changes: 13 additions & 13 deletions platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,19 @@
"@pydantic/genai-prices": "^0.1.12",
"@react-email/render": "2.1.0",
"@scalar/api-reference-react": "^0.9.81",
"@tanstack/ai": "^0.69.0",
"@tanstack/ai-anthropic": "^0.22.0",
"@tanstack/ai-gemini": "^0.38.0",
"@tanstack/ai-grok": "^0.22.0",
"@tanstack/ai-mcp": "0.9.0",
"@tanstack/ai-openai": "^0.30.0",
"@tanstack/ai-openrouter": "^0.22.0",
"@tanstack/ai-persistence": "0.9.0",
"@tanstack/ai-sandbox": "^0.5.23",
"@tanstack/ai-sandbox-daytona": "^0.3.4",
"@tanstack/ai-sandbox-docker": "^0.3.2",
"@tanstack/ai-sandbox-sprites": "^0.2.5",
"@tanstack/ai-sandbox-vercel": "^0.2.5",
"@tanstack/ai": "^1.0.0",
"@tanstack/ai-anthropic": "^1.0.0",
"@tanstack/ai-gemini": "^1.0.0",
"@tanstack/ai-grok": "^1.0.0",
"@tanstack/ai-mcp": "^1.0.0",
"@tanstack/ai-openai": "^1.0.0",
"@tanstack/ai-openrouter": "^1.0.0",
"@tanstack/ai-persistence": "^1.0.0",
"@tanstack/ai-sandbox": "^1.0.0",
"@tanstack/ai-sandbox-daytona": "^1.0.0",
"@tanstack/ai-sandbox-docker": "^1.0.0",
"@tanstack/ai-sandbox-sprites": "^1.0.0",
"@tanstack/ai-sandbox-vercel": "^1.0.0",
"@tanstack/highlight": "^1.1.0",
"@tanstack/markdown": "^1.0.0",
"@tanstack/react-devtools": "^0.10.13",
Expand Down
4 changes: 0 additions & 4 deletions platform/public/api/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion platform/src/lib/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ For example, an assistant's `metadata` can contain:

Optional fields also preserve input tool declarations, opaque `modelMessages`, provider instance/type, API protocol, model identity, and usage. Provider continuation data is replayed only with the same provider instance, protocol, and model. Otherwise the adapter uses portable canonical parts.

Assistant provenance usage includes a decimal-string `costUsd` when a reported cost or resolved token rate is available. This snapshots the generation-time cost for retained history. Directory totals exclude drafts and temporarily count unpriceable settled replies as zero.
Assistant provenance usage includes a decimal-string `costUsd` when a reported cost or resolved token rate is available. This snapshots the generation-time cost for retained history. Reported usage from failed model calls is retained and recorded once per model phase, including partial Anthropic input/cache usage. One-hour cache writes are priced as a subset of total cache writes. Directory totals exclude drafts and temporarily count unpriceable settled replies as zero.

`initiatorTenantUserId` identifies the actor of this foreground invocation. A participant resolving the last browser response can start a continuation, so that actor can differ from the original user-message author.

Expand Down
8 changes: 6 additions & 2 deletions platform/src/lib/chat/adapter-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const responsesRequestSchema = Schema.Struct({

describe("OpenAI Responses compatibility", () => {
test.each([
{ modelId: "gpt-5.6-terra", include: ["reasoning.encrypted_content"] },
{ modelId: "gpt-6.1-sol", include: ["reasoning.encrypted_content"] },
{ modelId: "custom-deployment", include: undefined },
])("preserves native request semantics for $modelId", async ({ modelId, include }) => {
const requests: (typeof responsesRequestSchema.Type)[] = []
Expand All @@ -35,7 +35,11 @@ describe("OpenAI Responses compatibility", () => {
modelId,
fetch: responsesFetch,
})
for await (const _event of chat({ adapter, messages: [{ role: "user", content: "Hello" }] })) {
for await (const _event of chat({
threadId: "provider-test",
adapter,
messages: [{ role: "user", content: "Hello" }],
})) {
// Drain the run so the SDK maps and sends its request to the synthetic provider boundary.
}
expect(requests).toHaveLength(1)
Expand Down
7 changes: 6 additions & 1 deletion platform/src/lib/chat/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ describe("chat provider request routing", () => {
fetch: configuredFetch,
})
const events: StreamChunk[] = []
for await (const event of chat({ adapter, messages: [{ role: "user", content: "Hello" }] }))
for await (const event of chat({
threadId: "provider-test",
adapter,
messages: [{ role: "user", content: "Hello" }],
}))
events.push(event)
expect(events.some((event) => event.type === EventType.RUN_ERROR)).toBe(true)
expect(requests).toHaveLength(1)
Expand Down Expand Up @@ -219,6 +223,7 @@ describe("native Gemini and Grok turns", () => {
}).server(() => ({ ok: true }))
const events: StreamChunk[] = []
for await (const event of chat({
threadId: "provider-test",
adapter,
tools: [lookup],
agentLoopStrategy: maxIterations(2),
Expand Down
11 changes: 1 addition & 10 deletions platform/src/lib/chat/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,19 +178,10 @@ function createChatProviderAdapter(configuration: ChatModelConfiguration, native
}

export function createChatAdapter(model: ChatModelConfiguration, web?: ChatWebState) {
const provider = createChatProviderAdapter(
const adapter = createChatProviderAdapter(
web ? { ...model, fetch: (input, init) => fetchChatWeb(model, web, input, init) } : model,
web !== undefined,
)
const adapter = Object.assign(provider, {
inputModalities: provider.inputModalities ?? (["text", "image", "document"] as const),
// TextAdapter omits `| undefined` here, as for inputModalities before, so chat() rejects every
// adapter under exactOptionalPropertyTypes. All-off equals absent. https://github.com/TanStack/ai/issues/1702
midConversationChannels: provider.midConversationChannels ?? {
tools: false,
systemPrompts: false,
},
})
if (!web) return adapter
const stream = adapter.chatStream.bind(adapter) as (
options: TextOptions,
Expand Down
2 changes: 0 additions & 2 deletions platform/src/lib/chat/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,6 @@ describe("Chat.run", () => {
type: EventType.RUN_ERROR,
runId: "run",
message,
error: { message },
})
}).pipe(Effect.provide(chatTestLayer({ agent: sandboxedAgent, model: CHAT_TEST_MODEL }))),
)
Expand All @@ -452,7 +451,6 @@ describe("Chat.run", () => {
runId: "run",
message: "Request aborted",
code: "aborted",
error: { message: "Request aborted", code: "aborted" },
})
}).pipe(Effect.provide(chatTestLayer({ agent: sandboxedAgent, model: CHAT_TEST_MODEL }))),
)
Expand Down
18 changes: 14 additions & 4 deletions platform/src/lib/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import type { ChatParams, ChatPrincipal } from "./types"
import { IS_DEVELOPMENT_SERVER } from "@/lib/runtime/environment.server"
import { errorReason } from "@/lib/runtime/failure-report.server"
import { UsageLimits } from "@/lib/usage-limits/usage-limits.server"
import { chatUsageLimitGuard } from "./usage-limits"
import { chatFailedUsageMiddleware, chatUsageLimitGuard } from "./usage-limits"

// Only recognized provider codes get actionable copy. Provider messages can contain credentials.
const modelErrorMessages: Readonly<Record<string, string>> = {
Expand Down Expand Up @@ -387,6 +387,10 @@ export class Chat extends Context.Service<
),
})
turn = managed.state
const accounting = [
...managed.middleware,
...(usageLimitGuard ? [usageLimitGuard.middleware] : []),
]
return chatEventStream((abortController) => {
const source = chat({
adapter: createChatAdapter(model, web),
Expand All @@ -398,11 +402,12 @@ export class Chat extends Context.Service<
// A sandbox command or publication can depend on an earlier tool's file write.
// https://github.com/TanStack/ai/blob/main/packages/ai/CHANGELOG.md#0640
toolExecution: "sequential",
repairToolInput: false,
// Interrupt snapshots preserve original uploads after provider normalization.
middleware: [
chatFailedUsageMiddleware(accounting),
createChatAttachmentSnapshotMiddleware(inputMessages, history),
...managed.middleware,
...(usageLimitGuard ? [usageLimitGuard.middleware] : []),
...accounting,
{
name: "mcp-presentation",
onStart: (ctx) =>
Expand Down Expand Up @@ -450,7 +455,6 @@ export class Chat extends Context.Service<
...(chunk.threadId === undefined ? {} : { threadId: chunk.threadId }),
...(chunk.runId === undefined ? {} : { runId: chunk.runId }),
message,
error: aborted ? { message, code: "aborted" } : { message },
...(aborted ? { code: "aborted" } : {}),
}
return Effect.as(
Expand All @@ -459,6 +463,12 @@ export class Chat extends Context.Service<
organizationId: principal.organization.id,
providerId: model.providerId,
code: errorReason(chunk),
...(typeof chunk.metadata?.tanstack?.errorType === "string"
? { errorType: chunk.metadata.tanstack.errorType.slice(0, 128) }
: {}),
...(typeof chunk.metadata?.tanstack?.requestId === "string"
? { requestId: chunk.metadata.tanstack.requestId.slice(0, 256) }
: {}),
}),
),
runError,
Expand Down
7 changes: 5 additions & 2 deletions platform/src/lib/chat/sandbox/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ const writeChatSandboxUploads = Effect.fn("writeChatSandboxUploads")(function* (
if (uploads.length === 0) return
const directory = `${resolveHarnessCwd(handle)}/${CHAT_ATTACHMENT_UPLOAD_DIRECTORY}`
// One level below a workspace that already exists, so this needs no recursive `mkdir`.
yield* chatSandboxCall(() => handle.fs.mkdir(directory), CHAT_SANDBOX_FILE_TIMEOUT_MS)
yield* chatSandboxCall(
(signal) => handle.fs.mkdir(directory, { signal }),
CHAT_SANDBOX_FILE_TIMEOUT_MS,
)
yield* Effect.forEach(
uploads,
(upload) =>
chatSandboxCall(
() => handle.fs.write(`${directory}/${upload.handle}`, upload.bytes),
(signal) => handle.fs.write(`${directory}/${upload.handle}`, upload.bytes, { signal }),
CHAT_SANDBOX_FILE_TIMEOUT_MS,
),
{ concurrency: "unbounded", discard: true },
Expand Down
47 changes: 45 additions & 2 deletions platform/src/lib/chat/sandbox/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"
import { describe, expect, it, vi } from "vitest"

import { clampSandboxText } from "./tools.ts"
import { Context, Effect } from "effect"
import type { SandboxFsOptions, SandboxHandle } from "@tanstack/ai-sandbox"

import { CHAT_SANDBOX_MAX_ARTIFACT_BYTES } from "./constants.ts"
import { clampSandboxText, createChatSandboxTools } from "./tools.ts"

describe("clampSandboxText", () => {
it("elides the middle so a failing command's last line survives", () => {
Expand All @@ -10,3 +14,42 @@ describe("clampSandboxText", () => {
expect(clamped.text.endsWith("THE-ERROR")).toBe(true)
})
})

it.each([
{ size: CHAT_SANDBOX_MAX_ARTIFACT_BYTES + 1, bytes: 0, reads: 0, published: false },
{ size: 4, bytes: CHAT_SANDBOX_MAX_ARTIFACT_BYTES + 1, reads: 1, published: false },
{ size: 4, bytes: 4, reads: 1, published: true },
{ size: new Error("Provider metadata unavailable"), bytes: 0, reads: 0, published: false },
])(
"publishes only readable artifacts within the size limit: %j",
async ({ size, bytes, reads, published }) => {
const readBytes = vi.fn(() => Promise.resolve(new Uint8Array(bytes)))
const publish = vi.fn(() => Effect.succeed("stored-file"))
const lstat = vi.fn((_path: string, _options?: SandboxFsOptions) =>
size instanceof Error ? Promise.reject(size) : Promise.resolve({ type: "file", size }),
)
const handle = {
workspaceRoot: "/workspace",
fs: { lstat, readBytes },
} as unknown as SandboxHandle
const tools = createChatSandboxTools({
session: { acquire: () => Effect.succeed(handle), prepareUploads: () => Effect.void },
services: Context.empty(),
publishArtifact: publish,
})
const tool = tools.find((tool) => tool.name === "sandbox_publish_artifact")!
const result: unknown = await tool.execute!({ path: "result.txt" })
expect(readBytes).toHaveBeenCalledTimes(reads)
expect(publish).toHaveBeenCalledTimes(published ? 1 : 0)
expect(lstat.mock.calls[0]?.[0]).toBe("/workspace/result.txt")
expect(lstat.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
expect(result).toHaveProperty(
published ? "fileId" : "refusal",
published
? "stored-file"
: size instanceof Error
? "The file could not be read. Check the path with sandbox_list_files."
: `Artifacts are limited to ${CHAT_SANDBOX_MAX_ARTIFACT_BYTES} bytes. Compress or split the file.`,
)
},
)
Loading
Loading