Skip to content
Open
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
37 changes: 11 additions & 26 deletions packages/junior/src/chat/agent-dispatch/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ import {
} from "@/chat/state/artifacts";
import {
getChannelConfigurationServiceById,
getPersistedSandboxState,
getPersistedThreadState,
mergeArtifactsState,
persistThreadStateById,
} from "@/chat/runtime/thread-state";
import type { SandboxRef } from "@/chat/sandbox/ref";
import { getStateAdapter } from "@/chat/state/adapter";
import { sendSlackReply } from "@/chat/slack/reply";
import { finalizeFailedTurnReplyWithEvent } from "@/chat/services/turn-failure-response";
Expand Down Expand Up @@ -110,15 +112,13 @@ function upsertDispatchUserMessage(args: {
async function persistRuntimePatch(args: {
artifacts?: ThreadArtifactsState;
conversation: ThreadConversationState;
sandboxDependencyProfileHash?: string;
sandboxId?: string;
sandbox?: SandboxRef;
threadId: string;
}): Promise<void> {
await persistThreadStateById(args.threadId, {
artifacts: args.artifacts,
conversation: args.conversation,
sandboxId: args.sandboxId,
sandboxDependencyProfileHash: args.sandboxDependencyProfileHash,
sandbox: args.sandbox,
});
}

Expand Down Expand Up @@ -281,14 +281,7 @@ export async function runAgentDispatchSlice(
}

let artifacts = coerceThreadArtifactsState(persisted);
let sandboxId =
typeof persisted.app_sandbox_id === "string"
? persisted.app_sandbox_id
: undefined;
let sandboxDependencyProfileHash =
typeof persisted.app_sandbox_dependency_profile_hash === "string"
? persisted.app_sandbox_dependency_profile_hash
: undefined;
let sandbox = getPersistedSandboxState(persisted);
const channelConfiguration = getChannelConfigurationServiceById(
dispatch.destination.channelId,
);
Expand Down Expand Up @@ -401,24 +394,19 @@ export async function runAgentDispatchSlice(
},
state: {
artifactState: artifacts,
sandbox: {
sandboxId,
sandboxDependencyProfileHash,
},
sandbox,
},
delivery: {
onAssistantMessage: deliverAssistantMessage,
},
durability: {
onSandboxAcquired: async (sandbox) => {
sandboxId = sandbox.sandboxId;
sandboxDependencyProfileHash = sandbox.sandboxDependencyProfileHash;
onSandboxRefChanged: async (nextSandbox) => {
sandbox = nextSandbox;
await persistRuntimePatch({
threadId: conversationId,
conversation,
artifacts,
sandboxId,
sandboxDependencyProfileHash,
sandbox,
});
},
onArtifactStateUpdated: async (nextArtifacts) => {
Expand All @@ -427,8 +415,7 @@ export async function runAgentDispatchSlice(
threadId: conversationId,
conversation,
artifacts,
sandboxId,
sandboxDependencyProfileHash,
sandbox,
});
},
},
Expand Down Expand Up @@ -503,9 +490,7 @@ export async function runAgentDispatchSlice(
threadId: conversationId,
conversation,
artifacts: nextArtifacts,
sandboxId: reply.sandboxId ?? sandboxId,
sandboxDependencyProfileHash:
reply.sandboxDependencyProfileHash ?? sandboxDependencyProfileHash,
sandbox: reply.sandbox ?? sandbox,
});
});
statePersisted = true;
Expand Down
20 changes: 6 additions & 14 deletions packages/junior/src/chat/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,7 @@ async function executeAgentRunInPrivacyContext(
const turnTimeoutBudgetMs = Math.max(0, turnDeadlineAtMs - replyStartedAtMs);

let resume: ResumeState | undefined;
let lastKnownSandboxId: string | undefined = state.sandbox?.sandboxId;
let lastKnownSandboxDependencyProfileHash: string | undefined =
state.sandbox?.sandboxDependencyProfileHash;
let lastKnownSandboxRef = state.sandbox;
let mcpToolManager: McpToolManager | undefined;
let connectedMcpProviders = new Set<string>();
let turnUsage: AgentTurnUsage | undefined;
Expand Down Expand Up @@ -327,10 +325,7 @@ async function executeAgentRunInPrivacyContext(
await recordConnectedMcpProvider(provider);
}
};
const getSandboxMetadata = () => ({
sandboxId: lastKnownSandboxId,
sandboxDependencyProfileHash: lastKnownSandboxDependencyProfileHash,
});
const getSandboxMetadata = () => ({ sandbox: lastKnownSandboxRef });

try {
const projection = await openConversationProjection({ conversationId });
Expand Down Expand Up @@ -648,10 +643,8 @@ async function executeAgentRunInPrivacyContext(
generatedFiles,
invokedSkill,
observers,
onSandboxMetadataChanged: (sandbox) => {
lastKnownSandboxId = sandbox.sandboxId;
lastKnownSandboxDependencyProfileHash =
sandbox.sandboxDependencyProfileHash;
onSandboxRefChanged: (sandbox) => {
lastKnownSandboxRef = sandbox;
},
policy,
preAgentPromptMessages,
Expand All @@ -671,7 +664,7 @@ async function executeAgentRunInPrivacyContext(
userInput,
});
mcpToolManager = wiring.mcpToolManager;
const sandboxExecutor = wiring.sandboxExecutor;
const getSandboxRef = wiring.getSandboxRef;
const getPendingAuthPause = wiring.getPendingAuthPause;
const toolsAfterHandoff = wiring.agentTools;

Expand Down Expand Up @@ -1195,8 +1188,7 @@ async function executeAgentRunInPrivacyContext(
userInput,
artifactStatePatch,
toolCalls,
sandboxId: sandboxExecutor.getSandboxId(),
sandboxDependencyProfileHash: sandboxExecutor.getDependencyProfileHash(),
sandbox: getSandboxRef(),
piMessages: [...agent.state.messages],
durationMs: Date.now() - replyStartedAtMs,
generatedFileCount: generatedFiles.length,
Expand Down
9 changes: 3 additions & 6 deletions packages/junior/src/chat/agent/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { ConversationPrivacy } from "@/chat/conversation-privacy";
import type { CredentialContext } from "@/chat/credentials/context";
import type { PiMessage } from "@/chat/pi/messages";
import type { Actor } from "@/chat/actor";
import type { SandboxAcquiredState } from "@/chat/sandbox/sandbox";
import type { SandboxRef } from "@/chat/sandbox/ref";
import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing";
import type { AuthorizationFlowMode } from "@/chat/services/auth-pause";
import type { AssistantStatusSpec } from "@/chat/slack/assistant-thread/status";
Expand Down Expand Up @@ -120,10 +120,7 @@ export interface AgentRunState {
artifactState?: ThreadArtifactsState;
pendingAuth?: ConversationPendingAuthState;
/** Persisted sandbox reuse state from prior slices of this conversation. */
sandbox?: {
sandboxId?: string;
sandboxDependencyProfileHash?: string;
};
sandbox?: SandboxRef;
}

/**
Expand Down Expand Up @@ -169,7 +166,7 @@ export interface AgentRunDurability {
recordPendingAuth?: (
pendingAuth: ConversationPendingAuthState | undefined,
) => void | Promise<void>;
onSandboxAcquired?: (sandbox: SandboxAcquiredState) => void | Promise<void>;
onSandboxRefChanged?: (sandbox: SandboxRef) => void | Promise<void>;
onArtifactStateUpdated?: (
artifactState: ThreadArtifactsState,
) => void | Promise<void>;
Expand Down
169 changes: 98 additions & 71 deletions packages/junior/src/chat/agent/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,108 @@
/**
* Run-scoped sandbox workspace.
* Agent-facing sandbox adaptation.
*
* Exposes a stable workspace port to tools while deferring the actual
* sandbox boot until a tool first touches the filesystem or runs a command,
* and rebinding when the executor's sandbox identity changes mid-run.
* The sandbox module owns provider lifecycle and recovery. This module adds
* agent-specific preparation, durable reference propagation, custom command
* routing, and generated-artifact materialization.
*/
import { logInfo, type LogContext } from "@/chat/logging";
import type { SandboxExecutor } from "@/chat/sandbox/sandbox";
import type { FileUpload } from "chat";
import { isUserActor, type Actor } from "@/chat/actor";
import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command";
import type { ChannelConfigurationService } from "@/chat/configuration/types";
import type { CredentialContext } from "@/chat/credentials/context";
import { listReferenceFiles } from "@/chat/discovery";
import type { LogContext } from "@/chat/logging";
import { writeSandboxGeneratedArtifacts } from "@/chat/runtime/generated-artifacts";
import { formatSandboxCommandResult } from "@/chat/sandbox/command-result";
import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing";
import type { SandboxRef } from "@/chat/sandbox/ref";
import { createSandbox, type SandboxTools } from "@/chat/sandbox/sandbox";
import type { SandboxWorkspace } from "@/chat/sandbox/workspace";
import type { Skill, SkillMetadata } from "@/chat/skills";
import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads";

/** Create a lazy-boot workspace port bound to the run's sandbox executor. */
export function createLazySandboxWorkspace(args: {
executor: SandboxExecutor;
spanContext: LogContext;
}): SandboxWorkspace {
let sandboxPromise: Promise<SandboxWorkspace> | undefined;
let sandboxPromiseId: string | undefined;
const clearSandboxPromise = (): void => {
sandboxPromise = undefined;
sandboxPromiseId = undefined;
};
const getSandbox = (reason: {
trigger: string;
path?: string;
cmd?: string;
cwd?: string;
}): Promise<SandboxWorkspace> => {
const currentSandboxId = args.executor.getSandboxId();
if (
sandboxPromise &&
sandboxPromiseId &&
currentSandboxId !== sandboxPromiseId
) {
clearSandboxPromise();
}
export interface AgentSandboxOptions {
ref?: SandboxRef;
skills: SkillMetadata[];
traceContext: LogContext;
tracePropagation?: SandboxEgressTracePropagationConfig;
credentialEgress?: CredentialContext;
actor?: Actor;
channelConfiguration?: ChannelConfigurationService;
configurationValues: Record<string, unknown>;
getActiveSkill(): Skill | null;
prepareSandbox(workspace: SandboxWorkspace): void | Promise<void>;
onRefChanged(ref: SandboxRef): void;
persistRef?(ref: SandboxRef): void | Promise<void>;
}

if (!sandboxPromise) {
logInfo(
"sandbox_boot_requested",
args.spanContext,
{
"app.sandbox.boot.trigger": reason.trigger,
...(reason.path ? { "file.path": reason.path } : {}),
...(reason.cmd ? { "process.executable.name": reason.cmd } : {}),
...(reason.cwd ? { "file.directory": reason.cwd } : {}),
},
"Lazy sandbox boot requested",
);
sandboxPromise = args.executor
.createSandbox()
.then((sandbox) => {
sandboxPromiseId = sandbox.sandboxId;
return sandbox;
})
.catch((error) => {
clearSandboxPromise();
throw error;
});
}
return sandboxPromise;
};
export interface AgentSandbox {
readonly tools: SandboxTools;
readonly workspace: SandboxWorkspace;
ref(): SandboxRef | undefined;
writeGeneratedArtifacts(
files: FileUpload[],
): Promise<GeneratedArtifactFileRef[]>;
}

function bashCommand(input: unknown): string | undefined {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return undefined;
}
const command = (input as { command?: unknown }).command;
return typeof command === "string" && command.trim()
? command.trim()
: undefined;
}

/** Create run-scoped sandbox capabilities adapted to the current agent context. */
export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox {
const sandbox = createSandbox({
ref: options.ref,
skills: options.skills,
referenceFiles: listReferenceFiles(),
traceContext: options.traceContext,
tracePropagation: options.tracePropagation,
credentialEgress: options.credentialEgress,
prepare: options.prepareSandbox,
onRefChanged: async (ref) => {
options.onRefChanged(ref);
await options.persistRef?.(ref);
},
});

return {
readFileToBuffer: async (input) =>
(
await getSandbox({
trigger: "workspace.readFileToBuffer",
path: input.path,
})
).readFileToBuffer(input),
runCommand: async (input) =>
(
await getSandbox({
trigger: "workspace.runCommand",
cmd: input.cmd,
cwd: input.cwd,
})
).runCommand(input),
workspace: sandbox.workspace,
ref: sandbox.ref,
tools: {
supports: sandbox.tools.supports,
async execute(params) {
const command =
params.toolName === "bash" ? bashCommand(params.input) : undefined;
if (command) {
const result = await maybeExecuteJrRpcCustomCommand(command, {
activeSkill: options.getActiveSkill(),
channelConfiguration: options.channelConfiguration,
actorId: isUserActor(options.actor)
? options.actor.userId
: undefined,
onConfigurationValueChanged: (key, value) => {
if (value === undefined) {
delete options.configurationValues[key];
return;
}
options.configurationValues[key] = value;
},
});
if (result.handled) {
return formatSandboxCommandResult(result.result);
}
}
return await sandbox.tools.execute(params);
},
},
async writeGeneratedArtifacts(files) {
return await writeSandboxGeneratedArtifacts(sandbox.workspace, files);
},
};
}
Loading
Loading