diff --git a/src/agent/message-client-skills.test.ts b/src/agent/message-client-skills.test.ts index 55cad6846..0f2a966ea 100644 --- a/src/agent/message-client-skills.test.ts +++ b/src/agent/message-client-skills.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { buildConversationMessagesCreateRequestBody } from "@/agent/message"; +import { + buildConversationMessagesCreateRequestBody, + sendMessageStreamWithBackend, +} from "@/agent/message"; +import type { Backend } from "@/backend"; describe("buildConversationMessagesCreateRequestBody client_skills", () => { test("includes client_skills alongside client_tools", () => { @@ -32,4 +36,46 @@ describe("buildConversationMessagesCreateRequestBody client_skills", () => { }, ]); }); + + test("an explicit empty runtime skill scope sends no client skills on every turn", async () => { + const requestBodies: Array<{ client_skills?: unknown[] }> = []; + const emptyStream = { + [Symbol.asyncIterator]() { + return { + next: async () => ({ done: true as const, value: undefined }), + }; + }, + }; + const backend = { + createConversationMessageStream: async ( + _conversationId: string, + body: { client_skills?: unknown[] }, + ) => { + requestBodies.push(body); + return emptyStream; + }, + } as unknown as Backend; + const preparedToolContext = { + contextId: "no-skills-context", + clientTools: [], + loadedToolNames: [], + }; + + for (const content of ["initial reflection", "continued reflection"]) { + await sendMessageStreamWithBackend( + backend, + "conv-reflector", + [{ type: "message", role: "user", content }], + { + agentId: "agent-reflector", + preparedToolContext, + skillSources: [], + skipImageNormalization: true, + }, + ); + } + + expect(requestBodies).toHaveLength(2); + expect(requestBodies.map((body) => body.client_skills)).toEqual([[], []]); + }); }); diff --git a/src/agent/message.ts b/src/agent/message.ts index a1eb7bd82..c3e6a53ce 100644 --- a/src/agent/message.ts +++ b/src/agent/message.ts @@ -10,6 +10,7 @@ import type { LettaStreamingResponse, } from "@letta-ai/letta-client/resources/agents/messages"; import type { MessageCreateParams as ConversationMessageCreateParams } from "@letta-ai/letta-client/resources/conversations/messages"; +import type { SkillSource } from "@/agent/skill-sources"; import { type Backend, getBackend } from "@/backend"; import { type ClientTool, @@ -196,6 +197,8 @@ export type SendMessageStreamOptions = { overrideModel?: string; /** Explicit turn-scoped tool snapshot. When present, bypasses the global registry. */ preparedToolContext?: PreparedToolExecutionContext; + /** Turn-scoped skill roots. [] disables all client skill discovery. */ + skillSources?: SkillSource[]; /** * Allow sending a cached previous response id for this request. Callers should * set this only for approval continuations that were fully auto-handled by @@ -321,7 +324,7 @@ export async function sendMessageStreamWithBackend( const { clientSkills, errors: clientSkillDiscoveryErrors } = await buildClientSkillsPayload({ agentId: opts.agentId, - skillSources: getSkillSources(), + skillSources: opts.skillSources ?? getSkillSources(), }); const resolvedConversationId = conversationId; diff --git a/src/app-server-client.test.ts b/src/app-server-client.test.ts index aa0f1c9d4..51fcc299b 100644 --- a/src/app-server-client.test.ts +++ b/src/app-server-client.test.ts @@ -123,12 +123,14 @@ describe("app-server client", () => { const responsePromise = client.runtimeStart({ create_agent: { body: { name: "SDK test" } }, create_conversation: { body: {} }, + skill_sources: [], }); expect(JSON.parse(control.sent[0] ?? "{}")).toMatchObject({ type: "runtime_start", request_id: "runtime-start-1", create_agent: { body: { name: "SDK test" } }, + skill_sources: [], }); control.receive({ diff --git a/src/tools/toolset.ts b/src/tools/toolset.ts index 363352c5b..0ef616992 100644 --- a/src/tools/toolset.ts +++ b/src/tools/toolset.ts @@ -1,5 +1,6 @@ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; import { resolveModel } from "@/agent/model"; +import type { SkillSource } from "@/agent/skill-sources"; import { getBackend } from "@/backend"; import { getClient } from "@/backend/api/client"; import type { MessageChannelToolDiscoveryScope } from "@/channels/message-tool"; @@ -443,6 +444,7 @@ export async function prepareToolExecutionContextForScope(params: { workingDirectory?: string; permissionModeState?: PermissionModeState; cachedAgent?: AgentState | null; + skillSources?: SkillSource[]; channelTurnSources?: import("@/channels/types").ChannelTurnSource[]; modContext?: ModContext; modEvents?: ModEvents; @@ -459,6 +461,7 @@ export async function prepareToolExecutionContextForScope(params: { workingDirectory, permissionModeState, cachedAgent, + skillSources, channelTurnSources: explicitChannelTurnSources, modContext, modEvents, @@ -541,6 +544,7 @@ export async function prepareToolExecutionContextForScope(params: { agentName: (agent as AgentState).name ?? null, conversationId: scopedConversationId, workingDirectory, + ...(skillSources !== undefined ? { skillSources } : {}), ...(channelToolScope.channels.length > 0 ? { channelToolScope } : {}), ...(inheritedChannelTurnSources.length > 0 ? { channelTurnSources: inheritedChannelTurnSources } diff --git a/src/types/protocol_v2.ts b/src/types/protocol_v2.ts index 89f822a62..2099de67e 100644 --- a/src/types/protocol_v2.ts +++ b/src/types/protocol_v2.ts @@ -30,6 +30,7 @@ import type { MessageListParams, } from "@letta-ai/letta-client/resources/conversations/messages"; import type { StopReasonType } from "@letta-ai/letta-client/resources/runs/runs"; +import type { SkillSource } from "@/agent/skill-sources"; export type DmPolicy = "pairing" | "allowlist" | "open"; @@ -830,6 +831,8 @@ export interface RuntimeStartCommand { cwd?: string | null; /** Initial permission mode for this runtime scope. */ mode?: DevicePermissionMode; + /** Skill roots enabled for this runtime. An empty array disables all skills. */ + skill_sources?: readonly SkillSource[]; /** Optional client metadata for diagnostics/future protocol negotiation. */ client_info?: RuntimeStartClientInfo; /** Whether to probe backend state for stale pending approvals before replaying state. Defaults to true. */ diff --git a/src/websocket/listen-client-protocol.test.ts b/src/websocket/listen-client-protocol.test.ts index 9c4004a7d..00767b65d 100644 --- a/src/websocket/listen-client-protocol.test.ts +++ b/src/websocket/listen-client-protocol.test.ts @@ -605,6 +605,7 @@ describe("listen-client parseServerMessage", () => { }, cwd: cwdDir, mode: "acceptEdits", + skill_sources: [], recover_approvals: false, }, socket as unknown as WebSocket, @@ -636,6 +637,12 @@ describe("listen-client parseServerMessage", () => { runtimeScope.conversation_id, ), ).toBe(cwdDir); + const scopedRuntime = [...listener.conversationRuntimes.values()].find( + (candidate) => + candidate.agentId === runtimeScope.agent_id && + candidate.conversationId === runtimeScope.conversation_id, + ); + expect(scopedRuntime?.skillSources).toEqual([]); expect(messages).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/src/websocket/listener/commands/runtime-start.ts b/src/websocket/listener/commands/runtime-start.ts index e094fb263..a970705ce 100644 --- a/src/websocket/listener/commands/runtime-start.ts +++ b/src/websocket/listener/commands/runtime-start.ts @@ -142,10 +142,10 @@ async function resolveRuntimeStartAgent( : parsed.create_agent.body; const agent = await backend.createAgent(body); if (withMemfs) { - // Finish memfs setup (settings, repo clone, legacy tool detach) without - // blocking runtime start. The tag is already stamped at creation, so - // lazy sync paths can complete this even if the process dies here. - void enableMemfsIfCloud(agent.id); + // runtime_start is a readiness boundary for SDK callers. Do not return + // the created agent while its MemFS clone can still replace the working + // tree: callers may seed and commit files immediately after startup. + await enableMemfsIfCloud(agent.id); } else { // Worker-style agent: no memfs of its own; a memory scope may be // provided per session (MEMORY_DIR + LETTA_MEMORY_DIR_EXPLICIT). @@ -197,6 +197,11 @@ async function applyRuntimeStartState( scope: RuntimeScope, scopedRuntime: ConversationRuntime, ): Promise { + // runtime_start is authoritative for this app-server session. Reset to the + // harness defaults when omitted; preserve [] as an explicit no-skills scope. + scopedRuntime.skillSources = + parsed.skill_sources !== undefined ? [...parsed.skill_sources] : null; + if (parsed.mode) { const mode = migratePermissionMode(parsed.mode); if (!mode) { diff --git a/src/websocket/listener/protocol-inbound.test.ts b/src/websocket/listener/protocol-inbound.test.ts index d385d87c4..bd0aa4741 100644 --- a/src/websocket/listener/protocol-inbound.test.ts +++ b/src/websocket/listener/protocol-inbound.test.ts @@ -29,6 +29,7 @@ describe("agent/conversation management protocol-inbound validators", () => { create_conversation: { body: { summary: "New conversation" } }, cwd: "/tmp/project", mode: "acceptEdits", + skill_sources: [], client_info: { name: "test", title: "Test", version: "1.0.0" }, external_tools: [ { @@ -122,6 +123,12 @@ describe("agent/conversation management protocol-inbound validators", () => { agent_id: "agent-1", mode: "bad", }, + { + type: "runtime_start", + request_id: "r0", + agent_id: "agent-1", + skill_sources: ["unknown"], + }, { type: "runtime_start", request_id: "r0", diff --git a/src/websocket/listener/protocol-inbound.ts b/src/websocket/listener/protocol-inbound.ts index 86696678a..aceeffe6d 100644 --- a/src/websocket/listener/protocol-inbound.ts +++ b/src/websocket/listener/protocol-inbound.ts @@ -461,6 +461,7 @@ export function isRuntimeStartCommand( create_conversation?: unknown; cwd?: unknown; mode?: unknown; + skill_sources?: unknown; client_info?: unknown; recover_approvals?: unknown; force_device_status?: unknown; @@ -478,6 +479,15 @@ export function isRuntimeStartCommand( isRuntimeStartCreateConversationOptions(c.create_conversation)) && (c.cwd === undefined || c.cwd === null || typeof c.cwd === "string") && (c.mode === undefined || isDevicePermissionMode(c.mode)) && + (c.skill_sources === undefined || + (Array.isArray(c.skill_sources) && + c.skill_sources.every( + (source) => + source === "bundled" || + source === "global" || + source === "agent" || + source === "project", + ))) && (c.client_info === undefined || isRuntimeStartClientInfo(c.client_info)) && (c.recover_approvals === undefined || typeof c.recover_approvals === "boolean") && diff --git a/src/websocket/listener/recovery.ts b/src/websocket/listener/recovery.ts index 63d9cf086..1dc8b7533 100644 --- a/src/websocket/listener/recovery.ts +++ b/src/websocket/listener/recovery.ts @@ -691,6 +691,9 @@ export async function resolveRecoveredApprovalResponse( recovered.conversationId, ), modEvents: ensureListenerModAdapter(runtime.listener).events, + ...(runtime.skillSources !== null + ? { skillSources: runtime.skillSources } + : {}), }); runtime.currentToolset = preparedToolContext.toolset; runtime.currentToolsetPreference = preparedToolContext.toolsetPreference; diff --git a/src/websocket/listener/runtime.ts b/src/websocket/listener/runtime.ts index 06d6e6544..b68cc3a00 100644 --- a/src/websocket/listener/runtime.ts +++ b/src/websocket/listener/runtime.ts @@ -159,6 +159,7 @@ export function createConversationRuntime( key: runtimeKey, agentId: normalizedAgentId, conversationId: normalizedConversationId, + skillSources: null, activeChannelTurnSources: null, activeChannelTurnBatchId: null, activeChannelTurnContextRecovered: false, diff --git a/src/websocket/listener/turn.ts b/src/websocket/listener/turn.ts index 602a97fb3..ca1e7d68c 100644 --- a/src/websocket/listener/turn.ts +++ b/src/websocket/listener/turn.ts @@ -720,6 +720,9 @@ export async function handleIncomingMessage( workingDirectory: turnWorkingDirectory, permissionModeState: turnPermissionModeState, cachedAgent, + ...(runtime.skillSources !== null + ? { skillSources: runtime.skillSources } + : {}), channelTurnSources: msg.channelTurnSources, modEvents: ensureListenerModAdapter(runtime.listener).events, }); @@ -734,6 +737,9 @@ export async function handleIncomingMessage( workingDirectory: turnWorkingDirectory, permissionModeState: turnPermissionModeState, preparedToolContext: preparedToolContext.preparedToolContext, + ...(runtime.skillSources !== null + ? { skillSources: runtime.skillSources } + : {}), skipImageNormalization: true, ...(providerFallback.overrideModel ? { overrideModel: providerFallback.overrideModel } diff --git a/src/websocket/listener/types.ts b/src/websocket/listener/types.ts index 30a63417a..850423366 100644 --- a/src/websocket/listener/types.ts +++ b/src/websocket/listener/types.ts @@ -5,6 +5,7 @@ import type { ApprovalDecision, ApprovalResult, } from "@/agent/approval-execution"; +import type { SkillSource } from "@/agent/skill-sources"; import type { ChannelTurnProgressBuilder } from "@/channels/progress"; import type { ChannelTurnSource } from "@/channels/types"; import type { ContextTracker } from "@/cli/helpers/context-tracker"; @@ -140,6 +141,8 @@ export type ConversationRuntime = { key: string; agentId: string | null; conversationId: string; + /** Runtime-start override; null means use the normal harness defaults. */ + skillSources: SkillSource[] | null; activeChannelTurnSources: ChannelTurnSource[] | null; activeChannelTurnBatchId: string | null; activeChannelTurnContextRecovered?: boolean;