Skip to content

Commit a038699

Browse files
authored
✨Feat: Agent supports model selection. (#3313)
* ✨Feat: Agent supports model selection. * ✨Feat: Agent supports model selection. [Specification Details] 1. The northbound interface now supports passing in `model_id`, incremental SQL addition is implemented, and test files have been added. * ✨Feat: Agent supports model selection. [Specification Details] 1. Conflict resolve. * ✨Feat: Agent supports model selection. [Specification Details] 1. Frontend Build Fixes. * ✨Feat: Agent supports model selection. [Specification Details] 1. Update sql.
1 parent 53b2d89 commit a038699

38 files changed

Lines changed: 2038 additions & 494 deletions

backend/apps/northbound_app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ async def run_chat(
206206
"or a list of attachment objects with full metadata.",
207207
examples=[["s3://nexent/attachments/user123/20260609_report.pdf"]],
208208
),
209+
model_id: Optional[int] = Body(
210+
None,
211+
embed=True,
212+
description="Optional model ID to use for this run. Overrides the agent's default "
213+
"model so different models can be used for Q&A on the same agent.",
214+
examples=[123],
215+
),
209216
meta_data: Optional[Dict[str, Any]] = Body(
210217
None,
211218
embed=True,
@@ -255,6 +262,7 @@ async def run_chat(
255262
attachments=attachments,
256263
meta_data=meta_data,
257264
tool_params=tool_params,
265+
model_id=model_id,
258266
idempotency_key=idempotency_key,
259267
)
260268
except LimitExceededError as e:

backend/consts/model.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -540,8 +540,7 @@ class AgentInfoRequest(BaseModel):
540540
description: Optional[str] = None
541541
business_description: Optional[str] = None
542542
author: Optional[str] = None
543-
model_name: Optional[str] = None
544-
model_id: Optional[int] = None
543+
model_ids: Optional[List[int]] = None
545544
max_steps: Optional[int] = Field(default=None, ge=1, le=30)
546545
requested_output_tokens: Optional[int] = Field(default=None, gt=0)
547546
provide_run_summary: Optional[bool] = None
@@ -652,8 +651,8 @@ class ExportAndImportAgentInfo(BaseModel):
652651
enabled: bool
653652
tools: List[ToolConfig]
654653
managed_agents: List[int]
655-
model_id: Optional[int] = None
656-
model_name: Optional[str] = None
654+
model_ids: Optional[List[int]] = None
655+
model_names: Optional[List[str]] = None
657656
business_logic_model_id: Optional[int] = None
658657
business_logic_model_name: Optional[str] = None
659658
skill_names: Optional[List[str]] = None

backend/database/agent_db.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,6 @@ def create_agent(agent_info, tenant_id: str, user_id: str):
220220
"display_name": new_agent.display_name,
221221
"description": new_agent.description,
222222
"author": new_agent.author,
223-
"model_id": new_agent.model_id,
224-
"model_name": new_agent.model_name,
225223
"max_steps": new_agent.max_steps,
226224
"duty_prompt": new_agent.duty_prompt,
227225
"constraint_prompt": new_agent.constraint_prompt,

backend/database/db_models.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -438,10 +438,8 @@ class AgentInfo(TableBase):
438438
display_name = Column(String(100), doc="Agent display name")
439439
description = Column(Text, doc="Description")
440440
author = Column(String(100), doc="Agent author")
441-
model_name = Column(
442-
String(100), doc="[DEPRECATED] Name of the model used, use model_id instead")
443-
model_id = Column(
444-
Integer, doc="Model ID, foreign key reference to model_record_t.model_id")
441+
model_ids = Column(
442+
ARRAY(Integer), doc="List of model IDs, foreign key references to model_record_t.model_id, max 5 models")
445443
max_steps = Column(Integer, doc="Maximum number of steps")
446444
duty_prompt = Column(Text, doc="Duty prompt content")
447445
constraint_prompt = Column(Text, doc="Constraint prompt content")

backend/services/agent_service.py

Lines changed: 162 additions & 81 deletions
Large diffs are not rendered by default.

backend/services/agent_version_service.py

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -303,12 +303,16 @@ def get_version_detail_impl(
303303
# Add enabled skills to result
304304
result['skills'] = [s for s in skills_snapshot if s.get('enabled', True)]
305305

306-
# Get model name from model_id
307-
if result.get('model_id') is not None and result['model_id'] != 0:
308-
model_info = get_model_by_model_id(result['model_id'])
309-
result['model_name'] = model_info.get('display_name', None) if model_info else None
310-
else:
311-
result['model_name'] = None
306+
# Get model names from model_ids array
307+
snapshot_model_ids = result.get("model_ids") or []
308+
snapshot_model_names: List[str] = []
309+
for mid in snapshot_model_ids:
310+
mid_info = get_model_by_model_id(mid)
311+
if mid_info and mid_info.get("display_name"):
312+
snapshot_model_names.append(mid_info["display_name"])
313+
result["model_names"] = snapshot_model_names
314+
# Derive legacy model_name from the first model for backward compatibility
315+
result["model_name"] = snapshot_model_names[0] if snapshot_model_names else None
312316

313317
# Get business logic model name
314318
if result.get('business_logic_model_id') is not None and result['business_logic_model_id'] != 0:
@@ -363,8 +367,8 @@ def _check_version_snapshot_availability(
363367
return False, [AgentUnavailableReason.AGENT_NOT_FOUND]
364368

365369
# Check model availability
366-
model_id = agent_info.get('model_id')
367-
if model_id is None or model_id == 0:
370+
model_ids = agent_info.get('model_ids') or []
371+
if not model_ids:
368372
unavailable_reasons.append(AgentUnavailableReason.MODEL_NOT_CONFIGURED)
369373

370374
# Check tools availability (only when tools are configured)
@@ -628,13 +632,21 @@ def compare_versions_impl(
628632
'value_b': version_b.get('name'),
629633
})
630634

631-
# Compare model_name
632-
if version_a.get('model_name') != version_b.get('model_name'):
635+
# Compare model_ids (canonical field). Both versions should always have the
636+
# same shape (list of ints) after get_version_detail_impl / draft path normalization.
637+
def _normalize_model_ids(value: Any) -> List[int]:
638+
if not value:
639+
return []
640+
return [int(x) for x in value if x is not None]
641+
642+
model_ids_a = _normalize_model_ids(version_a.get('model_ids'))
643+
model_ids_b = _normalize_model_ids(version_b.get('model_ids'))
644+
if model_ids_a != model_ids_b:
633645
differences.append({
634-
'field': 'model_name',
646+
'field': 'model_ids',
635647
'label': 'Model',
636-
'value_a': version_a.get('model_name'),
637-
'value_b': version_b.get('model_name'),
648+
'value_a': version_a.get('model_names') or model_ids_a,
649+
'value_b': version_b.get('model_names') or model_ids_b,
638650
})
639651

640652
# Compare max_steps
@@ -752,12 +764,15 @@ def _get_version_detail_or_draft(
752764
# Get published version detail (already includes skills from get_version_detail_impl)
753765
result = get_version_detail_impl(agent_id, tenant_id, version_no)
754766

755-
# Get model name from model_id
756-
if result.get('model_id') is not None and result['model_id'] != 0:
757-
model_info = get_model_by_model_id(result['model_id'])
758-
result['model_name'] = model_info.get('display_name', None) if model_info else None
759-
else:
760-
result['model_name'] = None
767+
# Get model names from model_ids array
768+
detail_model_ids = result.get("model_ids") or []
769+
detail_model_names: List[str] = []
770+
for mid in detail_model_ids:
771+
mid_info = get_model_by_model_id(mid)
772+
if mid_info and mid_info.get("display_name"):
773+
detail_model_names.append(mid_info["display_name"])
774+
result["model_names"] = detail_model_names
775+
result["model_name"] = detail_model_names[0] if detail_model_names else None
761776

762777
# Get business logic model name
763778
if result.get('business_logic_model_id') is not None and result['business_logic_model_id'] != 0:
@@ -908,12 +923,20 @@ async def list_published_agents_impl(
908923
agent = entry["raw_agent"]
909924
unavailable_reasons = list(dict.fromkeys(entry["unavailable_reasons"]))
910925

911-
model_id = agent.get("model_id")
912-
model_info = None
913-
if model_id is not None:
914-
if model_id not in model_cache:
915-
model_cache[model_id] = get_model_by_model_id(model_id, tenant_id)
916-
model_info = model_cache.get(model_id)
926+
# Build model_ids and model_names from agent snapshot
927+
model_ids_list = agent.get("model_ids")
928+
model_names_list: list[str] = []
929+
930+
if model_ids_list:
931+
# Resolve model names for each model_id
932+
for mid in model_ids_list:
933+
if mid not in model_cache:
934+
model_cache[mid] = get_model_by_model_id(mid, tenant_id)
935+
model_info_item = model_cache.get(mid)
936+
if model_info_item:
937+
model_names_list.append(model_info_item.get("display_name") or model_info_item.get("model_name") or str(mid))
938+
else:
939+
model_names_list.append(str(mid))
917940

918941
permission = resolve_agent_list_permission(
919942
user_role=user_role,
@@ -928,9 +951,9 @@ async def list_published_agents_impl(
928951
"display_name": agent.get("display_name") if agent.get("display_name") else agent.get("name"),
929952
"description": agent.get("description"),
930953
"author": agent.get("author"),
931-
"model_id": model_id,
932-
"model_name": model_info.get("model_name") if model_info is not None else agent.get("model_name"),
933-
"model_display_name": model_info.get("display_name") if model_info is not None else None,
954+
"model_ids": model_ids_list,
955+
"model_names": model_names_list,
956+
"model_name": model_names_list[0] if model_names_list else None,
934957
"is_available": len(unavailable_reasons) == 0,
935958
"unavailable_reasons": unavailable_reasons,
936959
"is_new": agent.get("is_new", False),

backend/services/northbound_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ async def start_streaming_chat(
326326
attachments: Optional[List[Any]] = None,
327327
meta_data: Optional[Dict[str, Any]] = None,
328328
tool_params: Optional[ToolParamsRequest] = None,
329+
model_id: Optional[int] = None,
329330
idempotency_key: Optional[str] = None
330331
) -> StreamingResponse:
331332
try:
@@ -360,6 +361,7 @@ async def start_streaming_chat(
360361
minio_files=normalized_attachments,
361362
is_debug=False,
362363
tool_params=tool_params,
364+
model_id=model_id,
363365
)
364366

365367
# Synchronously persist the user message before starting the stream to avoid race conditions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
-- Migration: Change ag_tenant_agent_t.model_id to model_ids (list of integers)
2+
-- Date: 2026-06-17
3+
-- Description: Migrate agent model configuration from single model_id to model_ids list
4+
--
5+
-- Idempotency notes:
6+
-- This migration is executed on every container restart together with all other
7+
-- incremental migrations. The follow-up migration
8+
-- v2.2.2_0626_drop_agent_model_id_and_model_name.sql
9+
-- removes ag_tenant_agent_t.model_id (and model_name). Therefore, on a re-run
10+
-- the model_id column may already be absent. Every step that references
11+
-- model_id must be guarded so the script remains a no-op in that state.
12+
--
13+
-- Migration strategy:
14+
-- 1. Add new model_ids column as ARRAY(Integer) if it doesn't already exist
15+
-- (idempotent via ADD COLUMN IF NOT EXISTS).
16+
-- 2. If model_id still exists, backfill model_ids from model_id only when
17+
-- model_ids is NULL or an empty array. Existing non-empty values are
18+
-- preserved so the migration does not clobber data written by newer code.
19+
-- 3. Set column comments (guarded so missing columns do not error).
20+
21+
SET search_path TO nexent;
22+
23+
BEGIN;
24+
25+
-- 1) Add model_ids column if it doesn't exist.
26+
-- ADD COLUMN IF NOT EXISTS is a no-op when the column already exists, so
27+
-- this statement is safe to re-run on every startup.
28+
ALTER TABLE nexent.ag_tenant_agent_t
29+
ADD COLUMN IF NOT EXISTS model_ids INTEGER[] DEFAULT NULL;
30+
31+
-- 2) Backfill model_ids from the legacy single-value model_id column.
32+
-- Only runs when model_id still exists. When model_id has already been
33+
-- dropped by a later migration (e.g. v2.2.2_0626_drop_agent_model_id_and_model_name.sql),
34+
-- this step is skipped and the script remains a safe no-op.
35+
-- "Empty" is defined as either NULL or an empty array ('{}'); both
36+
-- COALESCE(array_length(model_ids, 1), 0) = 0 and model_ids IS NULL match
37+
-- these cases. Rows whose model_ids already has values are left untouched.
38+
DO $$
39+
BEGIN
40+
IF EXISTS (
41+
SELECT 1 FROM information_schema.columns
42+
WHERE table_schema = 'nexent'
43+
AND table_name = 'ag_tenant_agent_t'
44+
AND column_name = 'model_id'
45+
) THEN
46+
UPDATE nexent.ag_tenant_agent_t
47+
SET model_ids = ARRAY[model_id]
48+
WHERE model_id IS NOT NULL
49+
AND (model_ids IS NULL OR COALESCE(array_length(model_ids, 1), 0) = 0);
50+
END IF;
51+
END $$;
52+
53+
-- 3) Update column comments.
54+
-- model_ids is created above (or was created on an earlier run) so the
55+
-- comment can be applied unconditionally. COMMENT ON COLUMN raises an
56+
-- error if the column is missing, so we still guard it for safety.
57+
DO $$
58+
BEGIN
59+
IF EXISTS (
60+
SELECT 1 FROM information_schema.columns
61+
WHERE table_schema = 'nexent'
62+
AND table_name = 'ag_tenant_agent_t'
63+
AND column_name = 'model_ids'
64+
) THEN
65+
COMMENT ON COLUMN nexent.ag_tenant_agent_t.model_ids IS
66+
'List of model IDs, foreign key references to model_record_t.model_id, max 5 models';
67+
END IF;
68+
END $$;
69+
70+
-- 4) Add a deprecation comment to model_id, only when the column still exists.
71+
-- Once v2.2.2_0626_drop_agent_model_id_and_model_name.sql has dropped it,
72+
-- this block is skipped.
73+
DO $$
74+
BEGIN
75+
IF EXISTS (
76+
SELECT 1 FROM information_schema.columns
77+
WHERE table_schema = 'nexent'
78+
AND table_name = 'ag_tenant_agent_t'
79+
AND column_name = 'model_id'
80+
) THEN
81+
COMMENT ON COLUMN nexent.ag_tenant_agent_t.model_id IS
82+
'[DEPRECATED] Single model ID, use model_ids instead';
83+
END IF;
84+
END $$;
85+
86+
COMMIT;

frontend/app/[locale]/agents/AgentVersionCard.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,14 @@ export function VersionCardItem({
315315
{
316316
key: '2',
317317
label: t("agent.version.field.modelName"),
318-
children: <span>{agentVersionDetail?.model_name}</span>,
318+
children: (
319+
<span>
320+
{Array.isArray((agentVersionDetail as any)?.model_names) &&
321+
(agentVersionDetail as any).model_names.length > 0
322+
? (agentVersionDetail as any).model_names.join(", ")
323+
: (agentVersionDetail as any)?.model_name || "-"}
324+
</span>
325+
),
319326
},
320327
];
321328

frontend/app/[locale]/agents/components/AgentSelectorHeader.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,24 @@ export default function AgentSelectorHeader({
262262
.map((id: any) => Number(id))
263263
.filter((id: number) => Number.isFinite(id));
264264

265+
// Ensure model_ids always has a value - fall back to single-element array
266+
// using the agent's first available legacy model_id (single-select) when
267+
// model_ids is empty in the response.
268+
const modelIdsForCopy = (() => {
269+
if (detail.model_ids && detail.model_ids.length > 0) return detail.model_ids;
270+
// Legacy payload may only carry model_id (single-select); preserve it
271+
const legacySingleId = (detail as { model_id?: number }).model_id;
272+
if (legacySingleId) return [legacySingleId];
273+
return undefined;
274+
})();
275+
265276
const createResult = await updateAgentMutation.mutateAsync({
266277
agent_id: undefined, // create
267278
name: copyName,
268279
display_name: copyDisplayName,
269280
description: detail.description,
270281
author: detail.author,
271-
model_name: detail.model,
272-
model_id: detail.model_id ?? undefined,
282+
model_ids: modelIdsForCopy,
273283
max_steps: detail.max_step,
274284
requested_output_tokens: detail.requested_output_tokens ?? null,
275285
provide_run_summary: detail.provide_run_summary,

0 commit comments

Comments
 (0)