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
2 changes: 1 addition & 1 deletion pins.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"t3": {
"repo": "pingdotgg/t3code",
"commit": "9bc4138923481dcada7f90d97ecaf0c42184bd4f",
"commit": "f855f016b3fe103f0b6cb05f26de08cb735b1f7b",
"paths": [
"apps/server/src/mcp/toolkits/attachment/tools.ts",
"apps/server/src/mcp/toolkits/device/tools.ts",
Expand Down
4 changes: 2 additions & 2 deletions t3/contract/apps/server/src/mcp/toolkits/device/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure]

const DeviceListTool = Tool.make("device_list", {
description:
"List iOS Simulators and Android Emulators here, what each host can run, and what is open in this thread's Device panel. Call before `device_open` when you do not know a device id.",
"List iOS Simulators and Android Emulators on every device host, what each host can run, and what is open in this thread's Device panel. Call before `device_open` when you do not know a device id.",
// An empty struct serializes as `anyOf [object, array]`, which some
// providers reject and then drop every tool on the server with it.
parameters: Schema.Struct({
Expand All @@ -49,7 +49,7 @@ const DeviceListTool = Tool.make("device_list", {

const DeviceOpenTool = Tool.make("device_open", {
description:
"Open a simulator or emulator for this thread. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.",
"Open a simulator or emulator for this thread. Pass `deviceId` and `hostId` from `device_list`. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.",
parameters: DeviceToolOpenInput,
success: DeviceToolOpenResult,
failure: DeviceToolFailure,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const CreateThreadsTool = Tool.make("create_threads", {

const ThreadListTool = Tool.make("t3_thread_list", {
description:
"List threads in a project, newest first. Omit `projectId` for yours. Filter by status, title, settled, or snoozed. Page with the cursor.",
"List threads newest first, in one project or with `allProjects`. Page with `cursor`.",
parameters: OrchestratorMcpThreadListInput,
success: OrchestratorMcpThreadListResult,
failure: OrchestratorMcpFailure,
Expand Down
2 changes: 2 additions & 0 deletions t3/contract/apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import * as FileSystem from "effect/FileSystem";
import * as DocumentText from "../../../provider/DocumentText.ts";
import * as ServerConfig from "../../../config.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as ServerSettings from "../../../serverSettings.ts";
import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -166,6 +167,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
FileSystem.FileSystem,
ServerConfig.ServerConfig,
ServerSettings.ServerSettingsService,
ProviderRegistry.ProviderRegistry,
DocumentText.DocumentText,
],
})
Expand Down
90 changes: 81 additions & 9 deletions t3/contract/packages/contracts/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,32 @@ export const EasDeviceHostConfig = Schema.Struct({
);
export type EasDeviceHostConfig = typeof EasDeviceHostConfig.Type;

export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig;
/**
* Alpha: devices leased from a Simlock gateway (https://incubator.callstack.com/simlock).
* T3 never installs Simlock; the gateway and its workers are the user's own.
*/
export const SimlockDeviceHostConfig = Schema.Struct({
kind: Schema.Literal("simlock"),
id: ConfiguredDeviceHostId,
label: TrimmedNonEmptyString,
/** The gateway's origin, such as https://gateway.example.ts.net:4700. */
gatewayUrl: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/[^\s/]+\/?$/)),
/** Leases, drives, watches, and takes input on its own leases. Kept in the secret store. */
agentToken: TrimmedString.pipe(
Schema.withDecodingDefaultKey(Effect.succeed("")),
Schema.annotateKey({ settingsSecret: true }),
),
maxLeases: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(2)),
),
/** A lease the Device panel keeps alive ends this long after it started. */
maxLeaseMinutes: Schema.Int.check(Schema.isBetween({ minimum: 5, maximum: 240 })).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(60)),
),
});
export type SimlockDeviceHostConfig = typeof SimlockDeviceHostConfig.Type;

export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig | SimlockDeviceHostConfig;

const uniqueHostIds = Schema.makeFilter(
(hosts: ReadonlyArray<{ readonly id: string }>) =>
Expand All @@ -85,6 +110,9 @@ export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(uniq
*/
export const EasDeviceHostConfigs = Schema.Array(EasDeviceHostConfig).check(uniqueHostIds);

/** Kept apart from the other host lists for the same reason as EAS hosts. */
export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).check(uniqueHostIds);

export const DeviceEasAccountInput = Schema.Struct({
/** A saved EAS host whose stored token is checked when no new token is given. */
hostId: Schema.optional(DeviceHostId),
Expand All @@ -99,6 +127,33 @@ export const DeviceEasAccount = Schema.Struct({
});
export type DeviceEasAccount = typeof DeviceEasAccount.Type;

export const DeviceSimlockIdentityInput = Schema.Struct({
/** Without one, the gateway this machine's agent-device points at. */
gatewayUrl: Schema.optional(TrimmedString),
/** A saved Simlock host whose stored token is checked when no new token is given. */
hostId: Schema.optional(DeviceHostId),
agentToken: Schema.optional(TrimmedString),
});
export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type;

export const DeviceSimlockIdentity = Schema.Struct({
gatewayUrl: Schema.String,
/** The label the gateway gave the token. */
signedInAs: Schema.String,
/** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */
tokenSource: Schema.Literals(["entered", "settings", "env"]),
});
export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type;

export class DeviceSimlockIdentityError extends Schema.TaggedError<DeviceSimlockIdentityError>()(
"DeviceSimlockIdentityError",
{ reason: Schema.String },
) {
override get message(): string {
return this.reason;
}
}

/** Checking an Expo credential failed; no device host is involved yet. */
export class DeviceEasAccountError extends Schema.TaggedError<DeviceEasAccountError>()(
"DeviceEasAccountError",
Expand Down Expand Up @@ -161,7 +216,15 @@ const EasDeviceHostSummary = Schema.Struct({
...deviceHostSummaryFields,
kind: Schema.Literal("eas"),
});
export const DeviceHostSummary = Schema.Union([MachineDeviceHostSummary, EasDeviceHostSummary]);
const SimlockDeviceHostSummary = Schema.Struct({
...deviceHostSummaryFields,
kind: Schema.Literal("simlock"),
});
export const DeviceHostSummary = Schema.Union([
MachineDeviceHostSummary,
EasDeviceHostSummary,
SimlockDeviceHostSummary,
]);
export type DeviceHostSummary = typeof DeviceHostSummary.Type;

export const DeviceHostStatus = Schema.Literals([
Expand Down Expand Up @@ -198,6 +261,8 @@ const deviceServiceStateFields = {
supportsToolInspection: Schema.optional(Schema.Boolean),
/** Servers from before EAS hosts ignore `easDeviceHosts`, so clients offer them only when set. */
supportsEasHosts: Schema.optional(Schema.Boolean),
/** Servers from before Simlock hosts ignore `simlockDeviceHosts`. */
supportsSimlockHosts: Schema.optional(Schema.Boolean),
hostStatus: DeviceHostStatus,
hostStatusDetail: Schema.optional(Schema.String),
hostStatuses: DeviceHostStatuses,
Expand All @@ -221,6 +286,7 @@ const DeviceServiceStateWire = Schema.Struct({
...deviceServiceStateFields,
hosts: Schema.Array(MachineDeviceHostSummary),
easHosts: Schema.optional(Schema.Array(EasDeviceHostSummary)),
simlockHosts: Schema.optional(Schema.Array(SimlockDeviceHostSummary)),
});
const DeviceServiceStateDecoded = Schema.Struct({
...deviceServiceStateFields,
Expand All @@ -234,18 +300,22 @@ export const DeviceServiceState = DeviceServiceStateWire.pipe(
typeof DeviceServiceStateDecoded.Type,
typeof DeviceServiceStateWire.Type
>({
decode: ({ easHosts, ...state }) => ({
decode: ({ easHosts, simlockHosts, ...state }) => ({
...state,
hosts: [...state.hosts, ...(easHosts ?? [])],
hosts: [...state.hosts, ...(easHosts ?? []), ...(simlockHosts ?? [])],
}),
encode: (state) => ({
...state,
hosts: state.hosts.filter(
(host): host is typeof MachineDeviceHostSummary.Type => host.kind !== "eas",
(host): host is typeof MachineDeviceHostSummary.Type =>
host.kind === "local" || host.kind === "ssh",
),
easHosts: state.hosts.filter(
(host): host is typeof EasDeviceHostSummary.Type => host.kind === "eas",
),
simlockHosts: state.hosts.filter(
(host): host is typeof SimlockDeviceHostSummary.Type => host.kind === "simlock",
),
}),
}),
),
Expand Down Expand Up @@ -492,13 +562,14 @@ export class DeviceBootError extends Schema.TaggedError<DeviceBootError>()("Devi
}) {
override get message(): string {
const explanation = {
disk_space: "There is not enough free disk space on the environment server.",
disk_space: "There is not enough free disk space on its device host.",
timeout: "The device did not become ready in time.",
launch_failed:
"The simulator or emulator could not start. Check its configuration on the environment server.",
launch_failed: "The simulator or emulator could not start.",
}[this.reason];
const summary = `Device ${this.deviceId} failed to boot: ${explanation}`;
return this.diagnostics ? `${summary}\n\n${this.diagnostics}` : summary;
return this.diagnostics
? `${summary}\n\n${this.diagnostics}`
: `${summary} The device host sent no details. Try again or open another device; if it keeps failing, check the device on that host.`;
}
}

Expand Down Expand Up @@ -584,6 +655,7 @@ export const DeviceToolOpenResult = Schema.Struct({
agentDevice: Schema.Struct({
command: Schema.String,
targetArgs: Schema.Array(Schema.String),
session: Schema.String,
}),
quickStart: Schema.String,
});
Expand Down
5 changes: 4 additions & 1 deletion t3/contract/packages/contracts/src/orchestratorMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export const OrchestratorMcpEnvironmentTarget = Schema.optional(
export const OrchestratorMcpThreadListInput = Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
projectId: OrchestratorMcpProjectTarget,
allProjects: Schema.optional(Schema.Boolean),
statuses: Schema.optional(
Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)),
),
Expand All @@ -353,6 +354,7 @@ export type OrchestratorMcpThreadPullRequest = typeof OrchestratorMcpThreadPullR

export const OrchestratorMcpThreadListItem = Schema.Struct({
threadId: ThreadId,
projectId: ProjectId,
title: Schema.String,
link: Schema.optional(Schema.String),
createdBy: OrchestrationV2Actor,
Expand All @@ -378,7 +380,8 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({
export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem.Type;

export const OrchestratorMcpThreadListResult = Schema.Struct({
projectId: ProjectId,
/** null with `allProjects`. */
projectId: Schema.NullOr(ProjectId),
currentThreadId: Schema.NullOr(ThreadId),
threads: Schema.Array(OrchestratorMcpThreadListItem),
nextCursor: Schema.NullOr(NonNegativeInt),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ export const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `
When browser work was asked, \`preview_*\` is the browser you share with the user: \`preview_status\` first, then \`preview_open\` if no tab can be automated. Prefer snapshot locators over coordinates. Switch browsers only if the tools are absent or \`preview_open\` says unsupported, the user asks, or two preview calls fail on one step. Then quote the error and switch.
`;

const T3_CODE_DEVICE_TOOLS = ["device_list", "device_open", "device_screenshot", "device_close"];

const deviceToolInstructions = (loadTools: string) => `## T3 Code devices

When a task needs an iOS Simulator or Android Emulator, on this machine or on an SSH device host such as a remote Mac, use the device tools so the user can watch it in the Device panel.${loadTools}
1. Call \`device_list\` unless you already have the device's \`hostId\` and \`id\`.
2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its snapshots, screenshots and input need \`open <app>\` first, and \`help <command>\` lists the options.
3. Call \`device_screenshot\` to look at the screen.

Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool or the command it returned fails, quote its error and follow its advice once, but never reuse or close another session; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`;

/** Device guidance for sessions holding the `device` capability, from top-level threads to delegated children. */
export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = deviceToolInstructions("");

/** Claude Code defers MCP tools behind ToolSearch, so Claude's copy says how to load them. */
export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH = deviceToolInstructions(
` Load them with ToolSearch \`select:${T3_CODE_DEVICE_TOOLS.map((tool) => `mcp__t3-code__${tool}`).join(",")}\`.`,
);

// ACP only: some ACP agents accept the injected MCP server but never expose
// its tools, so they need the terminal transport other harnesses never use.
const T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS = `ACP fallback: if the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call them through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset for a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`.`;
Expand All @@ -51,6 +70,7 @@ Investigate with read-only actions and do not edit files or otherwise execute th
export interface T3AcpInstructionState {
readonly interactionMode: ProviderInteractionMode;
readonly hasT3Mcp: boolean;
readonly hasDeviceTools: boolean;
}

/** ACP lacks system/developer fields; inject context on first prompt and tool/mode changes. */
Expand All @@ -63,7 +83,8 @@ export function t3AcpPromptWithInstructions(input: {
if (input.prompt.trimStart().startsWith("/")) return input.prompt;
if (
input.previousState?.interactionMode === input.state.interactionMode &&
input.previousState.hasT3Mcp === input.state.hasT3Mcp
input.previousState.hasT3Mcp === input.state.hasT3Mcp &&
input.previousState.hasDeviceTools === input.state.hasDeviceTools
) {
return input.prompt;
}
Expand All @@ -74,6 +95,7 @@ export function t3AcpPromptWithInstructions(input: {
...(input.state.hasT3Mcp
? [
T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim(),
...(input.state.hasDeviceTools ? [T3_CODE_DEVICE_TOOL_INSTRUCTIONS] : []),
T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim(),
T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS,
]
Expand All @@ -82,20 +104,38 @@ export function t3AcpPromptWithInstructions(input: {
return `<t3_code_instructions>\n${instructions.join("\n\n")}\n</t3_code_instructions>\n\n<user_request>\n${input.prompt}\n</user_request>`;
}

function prependT3OrchestrationInstructions(prompt: string): string {
return `<t3_code_orchestration_instructions>${T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()}</t3_code_orchestration_instructions>\n\n<user_request>\n${prompt}\n</user_request>`;
const orchestrationInstructions = (hasDeviceTools: boolean) =>
hasDeviceTools
? `${T3_CODE_ORCHESTRATION_INSTRUCTIONS}\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS}\n`
: T3_CODE_ORCHESTRATION_INSTRUCTIONS;

function prependT3OrchestrationInstructions(prompt: string, instructions: string): string {
return `<t3_code_orchestration_instructions>${instructions.trim()}</t3_code_orchestration_instructions>\n\n<user_request>\n${prompt}\n</user_request>`;
}

export function t3OrchestrationPromptForFirstRun(input: {
/** For providers whose instructions ride on the prompt, so a compacted conversation loses them. */
export function t3OrchestrationPrompt(input: {
readonly prompt: string;
readonly runOrdinal: number;
readonly hasT3Mcp: boolean;
readonly hasDeviceTools: boolean;
readonly orchestrationDelivered: boolean;
readonly deviceInstructionsDelivered: boolean;
}): string {
return input.runOrdinal === 1 && input.hasT3Mcp
? prependT3OrchestrationInstructions(input.prompt)
if (!input.hasT3Mcp) return input.prompt;
if (!input.orchestrationDelivered) {
return prependT3OrchestrationInstructions(
input.prompt,
orchestrationInstructions(input.hasDeviceTools),
);
}
return input.hasDeviceTools && !input.deviceInstructionsDelivered
? prependT3OrchestrationInstructions(input.prompt, T3_CODE_DEVICE_TOOL_INSTRUCTIONS)
: input.prompt;
}

export function t3OrchestrationSystemPrompt(hasT3Mcp: boolean): string | undefined {
return hasT3Mcp ? T3_CODE_ORCHESTRATION_INSTRUCTIONS : undefined;
export function t3OrchestrationSystemPrompt(
hasT3Mcp: boolean,
hasDeviceTools: boolean,
): string | undefined {
return hasT3Mcp ? orchestrationInstructions(hasDeviceTools) : undefined;
}
Loading