mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
import type {
|
||
AcpRuntime,
|
||
AcpRuntimeCatalogEntry,
|
||
AgentPersona,
|
||
CreateManagedAgentInput,
|
||
} from "@/shared/api/types";
|
||
import {
|
||
getDefaultPersonaRuntime,
|
||
resolvePersonaRuntime,
|
||
type ResolvePersonaRuntimeResult,
|
||
} from "./resolvePersonaRuntime";
|
||
import {
|
||
getDisabledAcpRuntimeIdsSnapshot,
|
||
runtimesForImplicitAcpSelection,
|
||
} from "./runtimeVisibilityPreference";
|
||
import {
|
||
resolveManagedAgentAvatarUrl,
|
||
type UploadMediaBytes,
|
||
} from "../ui/managedAgentAvatar";
|
||
|
||
type RuntimesQueryLike = {
|
||
isFetched: boolean;
|
||
data: readonly AcpRuntimeCatalogEntry[] | undefined;
|
||
refetch: () => Promise<{
|
||
data?: readonly AcpRuntimeCatalogEntry[] | undefined;
|
||
}>;
|
||
};
|
||
|
||
/**
|
||
* Acquire the available-runtime list for a start action (Phase 1B.3.5
|
||
* row 6). Refetch-aware: an unfetched query is fetched instead of being
|
||
* treated as an empty list (which would spuriously refuse every start).
|
||
*/
|
||
export async function availableRuntimesForStart(
|
||
query: RuntimesQueryLike,
|
||
): Promise<AcpRuntime[]> {
|
||
const entries = query.isFetched ? query.data : (await query.refetch()).data;
|
||
return (entries ?? []).filter(
|
||
(runtime): runtime is AcpRuntime => runtime.availability === "available",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve the runtime a definition should start on, refusing when the
|
||
* definition's configured runtime is not available (Phase 1B.3.5 row 1,
|
||
* Wes's call: one consistent refuse-with-actionable-error everywhere —
|
||
* never silently start on a different runtime than configured).
|
||
*/
|
||
export function resolveStartRuntimeForDefinition(
|
||
persona: AgentPersona,
|
||
runtimes: readonly AcpRuntime[],
|
||
preferredRuntimeId?: string | null,
|
||
disabledRuntimeIds: readonly string[] = getDisabledAcpRuntimeIdsSnapshot(),
|
||
): { runtime: AcpRuntime; warnings: string[] } {
|
||
const selectableRuntimes = runtimesForImplicitAcpSelection(
|
||
runtimes,
|
||
disabledRuntimeIds,
|
||
persona.runtime,
|
||
);
|
||
// Use the buzz-agent-first preference (buzz-agent → goose → first available)
|
||
// so a freshly installed goose never beats the bundled buzz-agent sidecar
|
||
// for runtime-less personas (item 13 regression guard).
|
||
const defaultRuntime = getDefaultPersonaRuntime(
|
||
selectableRuntimes,
|
||
preferredRuntimeId,
|
||
);
|
||
const { runtime, warnings, isOverridden }: ResolvePersonaRuntimeResult =
|
||
resolvePersonaRuntime(persona.runtime, selectableRuntimes, defaultRuntime);
|
||
|
||
if (!runtime) {
|
||
throw new Error("No available runtime found for this agent.");
|
||
}
|
||
if (isOverridden) {
|
||
throw new Error(
|
||
warnings[0] ??
|
||
"This agent's configured runtime is not available. Install the runtime or edit the agent before starting it.",
|
||
);
|
||
}
|
||
return { runtime, warnings };
|
||
}
|
||
|
||
/**
|
||
* Where the started instance should run when the user picked something other
|
||
* than plain local in the definition-create flow (B5). Absent intent =
|
||
* today's local mapping, byte-identical.
|
||
*
|
||
* - `provider`: remote backend. Mirrors the legacy provider-mode create:
|
||
* no local ACP/agent/MCP commands are spawned, so none are set;
|
||
* `startOnAppLaunch` is forced false (remote agents don't auto-start with
|
||
* the desktop) and `spawnAfterCreate` true.
|
||
* - `mesh`: relay-mesh compute. The preset patch carries the instance
|
||
* commands/env the legacy dialog fanned into its field state; env lands in
|
||
* record env_vars (the instance-override layer — the dial pointer is
|
||
* per-instance runtime state, never definition env). `harnessOverride`
|
||
* is true because the preset commands deliberately override the
|
||
* definition's runtime preference.
|
||
*/
|
||
export type BackendIntent = {
|
||
type: "provider";
|
||
id: string;
|
||
config: Record<string, unknown>;
|
||
};
|
||
|
||
/** Keep every definition-start surface aligned on runtime pinning semantics. */
|
||
export function shouldPinSelectedRuntimeForDefinition(
|
||
definitionRuntimeId: string | null | undefined,
|
||
selectedRuntimeId: string,
|
||
): boolean {
|
||
return (
|
||
!definitionRuntimeId || definitionRuntimeId.trim() === selectedRuntimeId
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The single definition→instance mapping (Phase 1B.3.5 rows 2–4). Every
|
||
* surface that creates a running instance from a definition builds its
|
||
* CreateManagedAgentInput here so the mapping cannot drift per-site.
|
||
*
|
||
* - harnessOverride uses the backend-aligned formula: true only when the
|
||
* definition has no runtime preference or the picked runtime matches it
|
||
* (`create_time_agent_command_override` stores None when picked ==
|
||
* inherited; on fallback `harness_override: false` keeps the definition
|
||
* authoritative).
|
||
* - avatarUrl goes through resolveManagedAgentAvatarUrl (base64 data URIs
|
||
* upload via the injectable `upload`; other URLs pass through unchanged).
|
||
* - envVars are never seeded from the definition: record.env_vars is
|
||
* agent overrides only and spawn merges the live definition env
|
||
* underneath. Seeding would manufacture pseudo-overrides that mask
|
||
* later definition edits made before the first spawn. (Mesh preset env is
|
||
* the deliberate exception: it is instance-override state, not
|
||
* definition env.)
|
||
*/
|
||
export async function buildInstanceInputForDefinition(
|
||
persona: AgentPersona,
|
||
runtime: AcpRuntime,
|
||
upload?: UploadMediaBytes,
|
||
backendIntent?: BackendIntent,
|
||
): Promise<CreateManagedAgentInput> {
|
||
const avatarUrl = await resolveManagedAgentAvatarUrl(
|
||
persona.avatarUrl,
|
||
upload,
|
||
);
|
||
|
||
const base = {
|
||
name: persona.displayName,
|
||
personaId: persona.id,
|
||
systemPrompt: persona.systemPrompt,
|
||
avatarUrl,
|
||
};
|
||
|
||
if (backendIntent?.type === "provider") {
|
||
return {
|
||
...base,
|
||
harnessOverride: false,
|
||
spawnAfterCreate: true,
|
||
startOnAppLaunch: false,
|
||
backend: {
|
||
type: "provider",
|
||
id: backendIntent.id,
|
||
config: backendIntent.config,
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
...base,
|
||
acpCommand: "buzz-acp",
|
||
agentCommand: runtime.command,
|
||
// Do NOT seed agentArgs from runtime.defaultArgs: record.agent_args must
|
||
// remain empty so spawn resolves args live from the definition on every
|
||
// start. Seeding here would freeze the args at create-time, silently
|
||
// ignoring any later definition-arg edits (Thufir F5 / phase B-5).
|
||
// envVars are intentionally never seeded for the same reason (see comment
|
||
// at top of this function).
|
||
agentArgs: [],
|
||
mcpCommand: runtime.mcpCommand ?? "",
|
||
harnessOverride: shouldPinSelectedRuntimeForDefinition(
|
||
persona.runtime,
|
||
runtime.id,
|
||
),
|
||
model: persona.model ?? undefined,
|
||
provider: persona.provider ?? undefined,
|
||
spawnAfterCreate: true,
|
||
startOnAppLaunch: true,
|
||
backend: { type: "local" },
|
||
};
|
||
}
|