Skip to content
Draft
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
48 changes: 47 additions & 1 deletion src/agent/message-client-skills.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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([[], []]);
});
});
5 changes: 4 additions & 1 deletion src/agent/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/app-server-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions src/tools/toolset.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -459,6 +461,7 @@ export async function prepareToolExecutionContextForScope(params: {
workingDirectory,
permissionModeState,
cachedAgent,
skillSources,
channelTurnSources: explicitChannelTurnSources,
modContext,
modEvents,
Expand Down Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions src/types/protocol_v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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. */
Expand Down
7 changes: 7 additions & 0 deletions src/websocket/listen-client-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ describe("listen-client parseServerMessage", () => {
},
cwd: cwdDir,
mode: "acceptEdits",
skill_sources: [],
recover_approvals: false,
},
socket as unknown as WebSocket,
Expand Down Expand Up @@ -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({
Expand Down
13 changes: 9 additions & 4 deletions src/websocket/listener/commands/runtime-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -197,6 +197,11 @@ async function applyRuntimeStartState(
scope: RuntimeScope,
scopedRuntime: ConversationRuntime,
): Promise<void> {
// 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) {
Expand Down
7 changes: 7 additions & 0 deletions src/websocket/listener/protocol-inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/websocket/listener/protocol-inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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") &&
Expand Down
3 changes: 3 additions & 0 deletions src/websocket/listener/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/websocket/listener/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export function createConversationRuntime(
key: runtimeKey,
agentId: normalizedAgentId,
conversationId: normalizedConversationId,
skillSources: null,
activeChannelTurnSources: null,
activeChannelTurnBatchId: null,
activeChannelTurnContextRecovered: false,
Expand Down
6 changes: 6 additions & 0 deletions src/websocket/listener/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions src/websocket/listener/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
Loading