mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
fix(agents): constrain builder models to runtime
This commit is contained in:
@@ -50,9 +50,26 @@ Return findings."}</agent_draft>`;
|
||||
});
|
||||
|
||||
it("round-trips only the user's natural-language request for chat display", () => {
|
||||
const content = encodeBuilderInput("Create a release manager", draft(), [], []);
|
||||
const content = encodeBuilderInput(
|
||||
"Create a release manager",
|
||||
draft(),
|
||||
[],
|
||||
[],
|
||||
{ id: "runtime-1", name: "Codex", provider: "codex" },
|
||||
[{ id: "gpt-5.5", label: "GPT-5.5", provider: "openai" }],
|
||||
);
|
||||
|
||||
expect(decodeBuilderInput(content)).toBe("Create a release manager");
|
||||
expect(JSON.parse(content.slice(content.indexOf("\n") + 1))).toMatchObject({
|
||||
selected_runtime: {
|
||||
id: "runtime-1",
|
||||
name: "Codex",
|
||||
provider: "codex",
|
||||
},
|
||||
available_runtime_models: [
|
||||
{ id: "gpt-5.5", label: "GPT-5.5", provider: "openai" },
|
||||
],
|
||||
});
|
||||
expect(decodeBuilderInput("ordinary chat message")).toBe(
|
||||
"ordinary chat message",
|
||||
);
|
||||
@@ -70,6 +87,7 @@ Return findings."}</agent_draft>`;
|
||||
},
|
||||
new Set(["skill-1", "skill-2"]),
|
||||
new Set(["member-1"]),
|
||||
new Set(["model-1"]),
|
||||
);
|
||||
|
||||
expect(result.name).toBe("Release manager");
|
||||
@@ -79,6 +97,86 @@ Return findings."}</agent_draft>`;
|
||||
expect([...result.memberIds]).toEqual(["member-1"]);
|
||||
});
|
||||
|
||||
it("accepts catalog models and rejects invented or cross-runtime models", () => {
|
||||
const validModelIds = new Set(["gpt-5.5", "gpt-5.3-codex"]);
|
||||
|
||||
expect(
|
||||
mergeBuilderDraft(
|
||||
draft(),
|
||||
{ model: "gpt-5.5" },
|
||||
new Set(),
|
||||
new Set(),
|
||||
validModelIds,
|
||||
).model,
|
||||
).toBe("gpt-5.5");
|
||||
expect(
|
||||
mergeBuilderDraft(
|
||||
draft(),
|
||||
{ model: "claude-3-5-sonnet" },
|
||||
new Set(),
|
||||
new Set(),
|
||||
validModelIds,
|
||||
).model,
|
||||
).toBe("model-1");
|
||||
expect(
|
||||
mergeBuilderDraft(
|
||||
draft(),
|
||||
{ model: "invented-model" },
|
||||
new Set(),
|
||||
new Set(),
|
||||
validModelIds,
|
||||
).model,
|
||||
).toBe("model-1");
|
||||
expect(
|
||||
mergeBuilderDraft(
|
||||
draft(),
|
||||
{ model: "" },
|
||||
new Set(),
|
||||
new Set(),
|
||||
validModelIds,
|
||||
).model,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("preserves a user-selected custom model when the catalog is unavailable", () => {
|
||||
expect(
|
||||
mergeBuilderDraft(
|
||||
draft(),
|
||||
{ model: "invented-model" },
|
||||
new Set(),
|
||||
new Set(),
|
||||
null,
|
||||
).model,
|
||||
).toBe("model-1");
|
||||
expect(
|
||||
mergeBuilderDraft(
|
||||
draft(),
|
||||
{ model: "model-1" },
|
||||
new Set(),
|
||||
new Set(),
|
||||
null,
|
||||
).model,
|
||||
).toBe("model-1");
|
||||
expect(
|
||||
mergeBuilderDraft(
|
||||
draft(),
|
||||
{ model: "invented-model" },
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
).model,
|
||||
).toBe("model-1");
|
||||
expect(
|
||||
mergeBuilderDraft(
|
||||
draft(),
|
||||
{ model: "" },
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
).model,
|
||||
).toBe("model-1");
|
||||
});
|
||||
|
||||
it("preserves scoped member and team grants when duplicating an agent", () => {
|
||||
const access = deriveDuplicateAccess({
|
||||
permission_mode: "public_to",
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
} from "@multica/core/chat/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import {
|
||||
runtimeListOptions,
|
||||
runtimeModelsOptions,
|
||||
} from "@multica/core/runtimes";
|
||||
import type {
|
||||
Agent,
|
||||
AgentInvocationTargetInput,
|
||||
@@ -35,6 +38,7 @@ import type {
|
||||
CreateAgentRequest,
|
||||
MemberWithUser,
|
||||
RuntimeDevice,
|
||||
RuntimeModel,
|
||||
} from "@multica/core/types";
|
||||
import {
|
||||
agentListOptions,
|
||||
@@ -223,6 +227,34 @@ export function AgentCreationStudio() {
|
||||
),
|
||||
[currentUser?.id, runtimes],
|
||||
);
|
||||
const selectedRuntime =
|
||||
runtimes.find((runtime) => runtime.id === draft.runtimeId) ?? null;
|
||||
const builderModelsQuery = useQuery(
|
||||
runtimeModelsOptions(
|
||||
mode === "ai" && selectedRuntime?.status === "online"
|
||||
? selectedRuntime.id
|
||||
: null,
|
||||
),
|
||||
);
|
||||
// `null` means discovery is not available yet (or failed), while `[]` is
|
||||
// an authoritative catalog with no selectable models. In both cases the
|
||||
// builder may preserve the user's current value but cannot invent one.
|
||||
const builderModelCatalog = useMemo(
|
||||
() =>
|
||||
builderModelsQuery.isSuccess
|
||||
? builderModelsQuery.data.supported
|
||||
? builderModelsQuery.data.models
|
||||
: []
|
||||
: null,
|
||||
[builderModelsQuery.data, builderModelsQuery.isSuccess],
|
||||
);
|
||||
const validBuilderModelIds = useMemo(
|
||||
() =>
|
||||
builderModelCatalog === null
|
||||
? null
|
||||
: new Set(builderModelCatalog.map((model) => model.id)),
|
||||
[builderModelCatalog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft.runtimeId || usableRuntimes.length === 0) return;
|
||||
@@ -284,12 +316,21 @@ export function AgentCreationStudio() {
|
||||
const payload = parseBuilderDraft(latestBuilderDraftMessageContent);
|
||||
if (!payload) return;
|
||||
appliedAssistantMessageRef.current = latestBuilderDraftMessageId;
|
||||
setDraft((current) => mergeBuilderDraft(current, payload, skillIdSet, memberIdSet));
|
||||
setDraft((current) =>
|
||||
mergeBuilderDraft(
|
||||
current,
|
||||
payload,
|
||||
skillIdSet,
|
||||
memberIdSet,
|
||||
validBuilderModelIds,
|
||||
),
|
||||
);
|
||||
}, [
|
||||
latestBuilderDraftMessageContent,
|
||||
latestBuilderDraftMessageId,
|
||||
memberIdSet,
|
||||
skillIdSet,
|
||||
validBuilderModelIds,
|
||||
]);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
@@ -303,8 +344,6 @@ export function AgentCreationStudio() {
|
||||
);
|
||||
}, [templateSearch, templates]);
|
||||
|
||||
const selectedRuntime =
|
||||
runtimes.find((runtime) => runtime.id === draft.runtimeId) ?? null;
|
||||
const accessInvalid =
|
||||
draft.permissionScope === "members" &&
|
||||
draft.memberIds.size === 0 &&
|
||||
@@ -425,6 +464,8 @@ export function AgentCreationStudio() {
|
||||
draft,
|
||||
workspaceSkills,
|
||||
members,
|
||||
selectedRuntime,
|
||||
builderModelCatalog,
|
||||
);
|
||||
const result = await api.sendChatMessage(
|
||||
builderSessionId,
|
||||
@@ -1429,6 +1470,8 @@ export function encodeBuilderInput(
|
||||
draft: AgentDraft,
|
||||
skills: Array<{ id: string; name: string; description: string }>,
|
||||
members: Array<{ user_id: string; name: string }>,
|
||||
runtime: Pick<RuntimeDevice, "id" | "name" | "provider"> | null,
|
||||
models: RuntimeModel[] | null,
|
||||
): string {
|
||||
return (
|
||||
BUILDER_INPUT_PREFIX +
|
||||
@@ -1444,6 +1487,21 @@ export function encodeBuilderInput(
|
||||
permission_scope: draft.permissionScope,
|
||||
member_ids: [...draft.memberIds],
|
||||
},
|
||||
selected_runtime: runtime
|
||||
? {
|
||||
id: runtime.id,
|
||||
name: runtime.name,
|
||||
provider: runtime.provider,
|
||||
}
|
||||
: null,
|
||||
available_runtime_models:
|
||||
models === null
|
||||
? null
|
||||
: models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
provider: model.provider,
|
||||
})),
|
||||
available_workspace_skills: skills.map((skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
@@ -1479,6 +1537,7 @@ export function mergeBuilderDraft(
|
||||
payload: BuilderDraftPayload,
|
||||
validSkillIds: Set<string>,
|
||||
validMemberIds: Set<string>,
|
||||
validModelIds: ReadonlySet<string> | null,
|
||||
): AgentDraft {
|
||||
const scope =
|
||||
payload.permission_scope === "workspace" ||
|
||||
@@ -1498,6 +1557,17 @@ export function mergeBuilderDraft(
|
||||
typeof id === "string" && validMemberIds.has(id),
|
||||
)
|
||||
: [...current.memberIds];
|
||||
// The current value may be a deliberate custom entry from ModelDropdown,
|
||||
// so preserving it is always safe. Only catalog IDs may be introduced by
|
||||
// the builder; failed discovery therefore cannot turn into fail-open input.
|
||||
const model =
|
||||
typeof payload.model === "string" &&
|
||||
(payload.model === current.model ||
|
||||
(validModelIds !== null &&
|
||||
validModelIds.size > 0 &&
|
||||
(payload.model === "" || validModelIds.has(payload.model))))
|
||||
? payload.model
|
||||
: current.model;
|
||||
|
||||
return {
|
||||
...current,
|
||||
@@ -1510,7 +1580,7 @@ export function mergeBuilderDraft(
|
||||
typeof payload.instructions === "string"
|
||||
? payload.instructions
|
||||
: current.instructions,
|
||||
model: typeof payload.model === "string" ? payload.model : current.model,
|
||||
model,
|
||||
skillIds: new Set(skillIds),
|
||||
permissionScope: scope,
|
||||
memberIds: new Set(scope === "members" ? memberIds : []),
|
||||
|
||||
@@ -26,6 +26,8 @@ Rules:
|
||||
- name is concise and suitable for a workspace list.
|
||||
- description is one sentence, at most 200 characters.
|
||||
- instructions are a complete Markdown system prompt describing role, workflow, output, and constraints.
|
||||
- model must be empty, preserve current_draft.model, or exactly match an id explicitly listed in AVAILABLE RUNTIME MODELS. Never use a model label as the id.
|
||||
- When AVAILABLE RUNTIME MODELS is null or empty, preserve current_draft.model and never invent a model id.
|
||||
- skill_ids may only contain IDs explicitly listed in AVAILABLE WORKSPACE SKILLS.
|
||||
- permission_scope must be private, workspace, or members. Default to private unless the user explicitly requests sharing.
|
||||
- member_ids may only contain IDs explicitly listed in AVAILABLE WORKSPACE MEMBERS, and only when permission_scope is members.
|
||||
|
||||
@@ -9,6 +9,18 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentBuilderInstructionsConstrainModelsToRuntimeCatalog(t *testing.T) {
|
||||
for _, requirement := range []string{
|
||||
"AVAILABLE RUNTIME MODELS",
|
||||
"Never use a model label as the id",
|
||||
"never invent a model id",
|
||||
} {
|
||||
if !strings.Contains(agentBuilderInstructions, requirement) {
|
||||
t.Fatalf("agent builder instructions missing model constraint %q", requirement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgentBuilderSessionCreatesIsolatedHiddenBuilder(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
|
||||
Reference in New Issue
Block a user