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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "TypeScript SDK, CLI, agent, and relayer for Tangle AI Cloud",
"scripts": {
"build": "pnpm -r build",
"test:scripts": "node --test scripts/*.test.mjs",
"dev": "pnpm --filter tcloud dev",
"dev:relayer": "pnpm --filter tcloud-relayer dev"
},
Expand Down
6 changes: 3 additions & 3 deletions packages/tcloud-agent/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/tcloud-agent",
"version": "0.3.3",
"version": "0.4.0",
"description": "Agent run-loop primitive over Tangle sandbox transports — runs an AgentProfile against a brief with criterion gates, budget caps, and streaming events. Includes TangleToolProvider for Pi tool integration.",
"type": "module",
"main": "./dist/index.js",
Expand Down Expand Up @@ -36,11 +36,11 @@
"build": "tsup src/index.ts src/pi-extension.ts --format esm --dts --clean",
"dev": "tsx src/index.ts",
"test": "vitest run",
"prepublishOnly": "pnpm build"
"prepublishOnly": "node ../../scripts/check-cohort-ranges.mjs && pnpm build"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"@tangle-network/sandbox": "^0.9.5",
"@tangle-network/sandbox": ">=0.27.1 <0.28.0",
"@tangle-network/tcloud": "workspace:^",
"viem": "^2.48.4"
},
Expand Down
45 changes: 30 additions & 15 deletions packages/tcloud-agent/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,10 @@ import { TCloudClient, type ChatCompletion, type ChatCompletionChunk, type ChatM
// ── Part types (wrappers over the sandbox SDK session-gateway shape) ─────────
//
// The sandbox SDK defines these in its `session-gateway/agent-connection.ts`
// (and canonically in `@tangle-network/agent-interface`). That package isn't
// published to the registry yet and older `@tangle-network/sandbox` builds do not
// re-export them, so we redeclare the minimal shape locally and re-export it
// for consumers. When the interface package ships we can flip these to a
// direct re-export without churning the consumer surface.
// and canonically in `@tangle-network/agent-interface`. `@tangle-network/sandbox`
// does not re-export them, so we redeclare the minimal shape here and re-export
// it for consumers. A direct `@tangle-network/agent-interface` dependency would
// let these become a re-export without churning the consumer surface.

/** Text delta emitted by the sandbox sidecar as the model streams tokens. */
export interface TextPart {
Expand Down Expand Up @@ -309,13 +308,23 @@ class SandboxSdkAgentSessionTransport implements AgentSessionTransport {
start(input: AgentSessionStart): AgentSession {
const sandbox = this.options.sandbox
const sessionId = input.resume ?? this.options.sessionId
const backend = this.options.backend ?? {}
// `backend.profile` carries an inline profile definition only. A cataloged
// profile is an id, so it travels on `PromptOptions.model`: the SDK folds
// that into `backend.model.model`, keeps the transport's provider, apiKey
// and baseUrl, rejects an empty id, and throws on a conflict with an
// explicit transport-level model instead of silently preferring one.
// The two selectors never travel together — one names the profile, the
// other carries it inline, and the wire defines no precedence between them.
const { profile: _transportProfile, ...backendWithoutProfile } = backend
const selection: Pick<PromptOptions, 'model' | 'backend'> =
typeof input.profile === 'string'
? { model: input.profile, backend: backendWithoutProfile }
: { backend: { ...backend, profile: input.profile } }
const promptOptions: PromptOptions = {
sessionId,
timeoutMs: this.options.timeoutMs,
backend: {
...(this.options.backend ?? {}),
profile: input.profile,
},
...selection,
context: input.workspace?.dir ? { workspaceDir: input.workspace.dir } : undefined,
}

Expand Down Expand Up @@ -717,13 +726,19 @@ function mergeSandbox(
}

function promptOptionsForTurn(base: PromptOptions, turn: AgentSessionChatOptions): PromptOptions {
const sessionId = turn.sandbox?.sessionId ?? base.sessionId
const inlineProfile = turn.sandbox?.agentProfile
if (!inlineProfile) return { ...base, sessionId }

// An inline profile for this turn replaces the session's cataloged selector,
// so the cataloged id goes. A transport-level `backend.model` stays: the
// sandbox treats an inline profile and a model override as separate fields,
// which is what `start()` sends for an inline profile as well.
const { model: _catalogedProfile, ...withoutCatalogedProfile } = base
return {
...base,
sessionId: turn.sandbox?.sessionId ?? base.sessionId,
backend: {
...(base.backend ?? {}),
...(turn.sandbox?.agentProfile ? { profile: turn.sandbox.agentProfile } : {}),
},
...withoutCatalogedProfile,
sessionId,
backend: { ...(base.backend ?? {}), profile: inlineProfile },
}
}

Expand Down
134 changes: 118 additions & 16 deletions packages/tcloud-agent/tests/agent-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,31 @@ async function collect(iter: AsyncIterable<AgentEvent>): Promise<AgentEvent[]> {

// ---- run() tests (renamed from runUntil) -----------------------------------

// ---- Fake sandbox -----------------------------------------------------------
//
// Records every prompt the sandbox SDK transport sends. `streamPrompt` throws
// so a test that expects the non-streaming path fails loudly if it takes the
// other one.

function makeRecordingSandbox() {
const prompts: Array<{ message: string; options: unknown }> = []
const sandbox = {
async prompt(message: string, options: unknown) {
prompts.push({ message, options })
return {
success: true,
response: 'sdk ok',
durationMs: 12,
usage: { inputTokens: 3, outputTokens: 4 },
}
},
async *streamPrompt() {
throw new Error('streamPrompt should not be used when stream:false')
},
}
return { prompts, sandbox }
}

describe('Agent.run', () => {
it('returns verified on iteration 1 when all criteria pass', async () => {
const { client, calls } = makeFakeClient([makeCompletion('build passed, tsc ok')])
Expand Down Expand Up @@ -369,21 +394,7 @@ describe('Agent.run', () => {
})

it('Sandbox SDK transport maps prompt responses into agent completions', async () => {
const prompts: Array<{ message: string; options: unknown }> = []
const sandbox = {
async prompt(message: string, options: unknown) {
prompts.push({ message, options })
return {
success: true,
response: 'sdk ok',
durationMs: 12,
usage: { inputTokens: 3, outputTokens: 4 },
}
},
async *streamPrompt() {
throw new Error('streamPrompt should not be used when stream:false')
},
}
const { prompts, sandbox } = makeRecordingSandbox()
const result = await agent({
transport: sandboxSdkTransport({ sandbox: sandbox as any }),
profile: 'sf-proposer',
Expand All @@ -396,8 +407,99 @@ describe('Agent.run', () => {
expect(prompts[0].message).toBe('hi')
expect(prompts[0].options).toMatchObject({
sessionId: 'sdk-session',
backend: { profile: 'sf-proposer' },
model: 'sf-proposer',
})
expect(
(prompts[0].options as { backend?: { profile?: unknown } }).backend?.profile,
).toBeUndefined()
})

it('Sandbox SDK transport keeps the transport model transport beside a cataloged profile', async () => {
const { prompts, sandbox } = makeRecordingSandbox()
await agent({
transport: sandboxSdkTransport({
sandbox: sandbox as any,
backend: { model: { provider: 'zai', apiKey: 'k' } } as any,
}),
profile: 'sf-proposer',
brief: 'hi',
stream: false,
}).run()
expect(prompts[0].options).toMatchObject({
model: 'sf-proposer',
backend: { model: { provider: 'zai', apiKey: 'k' } },
})
expect(
(prompts[0].options as { backend?: { model?: { model?: unknown } } }).backend?.model?.model,
).toBeUndefined()
})

it('Sandbox SDK transport drops a transport-level inline profile for a cataloged profile', async () => {
const { prompts, sandbox } = makeRecordingSandbox()
await agent({
transport: sandboxSdkTransport({
sandbox: sandbox as any,
backend: { profile: { model: { default: 'kimi-k2' } } } as any,
}),
profile: 'sf-proposer',
brief: 'hi',
stream: false,
}).run()
expect(prompts[0].options).toMatchObject({ model: 'sf-proposer' })
expect(
(prompts[0].options as { backend?: { profile?: unknown } }).backend?.profile,
).toBeUndefined()
})

it('Sandbox SDK transport swaps the cataloged id for a turn inline profile and keeps the model transport', async () => {
const { prompts, sandbox } = makeRecordingSandbox()
const turnProfile = { name: 'turn', prompt: 'be brief', model: { default: 'kimi-k2' } }
const session = sandboxSdkTransport({
sandbox: sandbox as any,
backend: { model: { provider: 'zai', apiKey: 'k', model: 'claude-x' } } as any,
}).start({ profile: 'sf-proposer' })

await session.chat({
messages: [{ role: 'user', content: 'hi' }],
sandbox: { agentProfile: turnProfile as any },
})

// The cataloged id goes; the operator's explicit model override survives,
// which is what `start()` sends for an inline profile as well.
expect(prompts[0].options).toMatchObject({
backend: { profile: turnProfile, model: { provider: 'zai', apiKey: 'k', model: 'claude-x' } },
})
expect((prompts[0].options as { model?: unknown }).model).toBeUndefined()
})

it('Sandbox SDK transport leaves a turn without an inline profile on the cataloged id', async () => {
const { prompts, sandbox } = makeRecordingSandbox()
const session = sandboxSdkTransport({ sandbox: sandbox as any }).start({
profile: 'sf-proposer',
})

await session.chat({ messages: [{ role: 'user', content: 'hi' }] })

expect(prompts[0].options).toMatchObject({ model: 'sf-proposer' })
expect(
(prompts[0].options as { backend?: { profile?: unknown } }).backend?.profile,
).toBeUndefined()
})

it('Sandbox SDK transport sends an inline profile on backend.profile', async () => {
const { prompts, sandbox } = makeRecordingSandbox()
const profile = { model: { default: 'kimi-k2' } }
await agent({
transport: sandboxSdkTransport({ sandbox: sandbox as any }),
profile: profile as any,
brief: 'hi',
stream: false,
}).run()
expect(prompts[0].options).toMatchObject({ backend: { profile } })
expect(
(prompts[0].options as { backend?: { model?: { model?: string } } }).backend?.model?.model,
).toBeUndefined()
expect((prompts[0].options as { model?: unknown }).model).toBeUndefined()
})

it('forces non-streaming when usd budget is set so cost accounting can run', async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/tcloud-attestation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"prepublishOnly": "node ../../scripts/check-cohort-ranges.mjs && pnpm build",
"test": "vitest run",
"check-types": "tsc --noEmit"
},
Expand Down
1 change: 1 addition & 0 deletions packages/tcloud-relayer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
],
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"prepublishOnly": "node ../../scripts/check-cohort-ranges.mjs && pnpm build",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
},
Expand Down
6 changes: 3 additions & 3 deletions packages/tcloud/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/tcloud",
"version": "0.4.14",
"version": "0.5.0",
"description": "TypeScript SDK and CLI for Tangle Router, Sandbox, model routing, and agent service calls",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -74,12 +74,12 @@
"test": "vitest run",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"dev": "tsx src/cli.ts",
"prepublishOnly": "npm run build"
"prepublishOnly": "node ../../scripts/check-cohort-ranges.mjs && npm run build"
},
"dependencies": {
"@scure/bip32": "^2.2.0",
"@scure/bip39": "^2.2.0",
"@tangle-network/sandbox": "^0.9.5",
"@tangle-network/sandbox": ">=0.27.1 <0.28.0",
"@tangle-network/tcloud-attestation": "workspace:^",
"commander": "^14.0.3",
"viem": "^2.48.4"
Expand Down
Loading