mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
Adds a first-class `model` field to agents so users can pick the LLM model
from the create / settings UI instead of editing custom_env / custom_args.
The previous "set MULTICA_<PROVIDER>_MODEL env var on the daemon" approach
forced one model per provider per machine and was easy to misconfigure
(e.g. -m as a custom_arg breaks codex app-server initialization).
Backend (server/pkg/agent):
- New `agent.ListModels(provider, path)` returns the models supported by a
provider. Static catalogs for claude, codex, gemini, cursor, copilot;
dynamic discovery for opencode (`opencode models`), pi (`pi --list-models`),
openclaw (`openclaw agents list`); 60s TTL cache + empty-list fallback on
failure. Hermes returns an empty list and `ModelSelectionSupported=false`
because its model is configured out-of-band.
- `agent.DefaultModel(provider)` returns the recommended default per
provider (Sonnet 4.6 for claude, GPT-5.4 for codex, Gemini 2.5 Pro for
gemini, composer-1.5 for cursor); copilot/openclaw/hermes deliberately
have no default. The static catalog tags one entry per provider with
`Default: true` so the UI can render a badge.
- For openclaw, opts.Model is mapped to `--agent <name>` since the CLI
rejects `--model` outright; custom_args `--agent` still wins for
back-compat.
Daemon protocol (server/internal/daemon):
- Heartbeat response carries an optional `pending_model_list` request
(same pattern as PingStore / UpdateStore). The daemon resolves models
via `agent.ListModels`, including the `supported` flag, and reports
back via /api/daemon/runtimes/{id}/models/{requestId}/result.
- Task dispatch uses a three-tier fallback for the runtime model:
agent.model → MULTICA_<PROVIDER>_MODEL env → agent.DefaultModel(provider).
Server API (server/internal/handler):
- `agent.model` is a new column (migration 050) and surfaces in
Agent / CreateAgent / UpdateAgent payloads.
- New endpoints under /api/runtimes/{id}/models: POST to initiate
discovery, GET to poll the request, plus the daemon-side report
endpoint above.
CLI (server/cmd/multica):
- `multica agent create / update --model <id>`. Help copy steers users
away from passing --model via --custom-args, which fails on codex
(app-server mode) and openclaw.
Frontend (packages/core, packages/views):
- `Agent.model`, `RuntimeModel`, `RuntimeModelListRequest`,
`RuntimeModelsResult` types.
- `runtimes/models.ts` exports `runtimeModelsOptions(runtimeId)` which
initiates discovery and polls the request to completion (500ms
cadence, 30s ceiling).
- New `ModelDropdown` (packages/views/agents) — searchable popover,
provider grouping, creatable manual entry, "default" badge on the
shipped recommendation, disabled state when the provider reports
`supported=false` (Hermes), and clears any stale model value in that
case to avoid persisting a ghost configuration.
- Wired into create-agent-dialog and the agent settings tab.
Verification:
- gofmt clean on touched files
- `go build ./... && go test ./...` (server) green; new openclaw and
models_test cases included
- `pnpm typecheck` green across all 6 packages
Closes the immediate UX gap behind MUL-1151. DeepSeek V4 (or any new
model) becomes a zero-code addition: add it to the relevant static
catalog, or rely on the creatable input for one-off use.
206 lines
7.6 KiB
SQL
206 lines
7.6 KiB
SQL
-- name: ListAgents :many
|
|
SELECT * FROM agent
|
|
WHERE workspace_id = $1 AND archived_at IS NULL
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: ListAllAgents :many
|
|
SELECT * FROM agent
|
|
WHERE workspace_id = $1
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: GetAgent :one
|
|
SELECT * FROM agent
|
|
WHERE id = $1;
|
|
|
|
-- name: GetAgentInWorkspace :one
|
|
SELECT * FROM agent
|
|
WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: CreateAgent :one
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, avatar_url, runtime_mode,
|
|
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
|
instructions, custom_env, custom_args, mcp_config, model
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAgent :one
|
|
UPDATE agent SET
|
|
name = COALESCE(sqlc.narg('name'), name),
|
|
description = COALESCE(sqlc.narg('description'), description),
|
|
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
|
|
runtime_config = COALESCE(sqlc.narg('runtime_config'), runtime_config),
|
|
runtime_mode = COALESCE(sqlc.narg('runtime_mode'), runtime_mode),
|
|
runtime_id = COALESCE(sqlc.narg('runtime_id'), runtime_id),
|
|
visibility = COALESCE(sqlc.narg('visibility'), visibility),
|
|
status = COALESCE(sqlc.narg('status'), status),
|
|
max_concurrent_tasks = COALESCE(sqlc.narg('max_concurrent_tasks'), max_concurrent_tasks),
|
|
instructions = COALESCE(sqlc.narg('instructions'), instructions),
|
|
custom_env = COALESCE(sqlc.narg('custom_env'), custom_env),
|
|
custom_args = COALESCE(sqlc.narg('custom_args'), custom_args),
|
|
mcp_config = COALESCE(sqlc.narg('mcp_config'), mcp_config),
|
|
model = COALESCE(sqlc.narg('model'), model),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: ClearAgentMcpConfig :one
|
|
UPDATE agent SET mcp_config = NULL, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: ArchiveAgent :one
|
|
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: RestoreAgent :one
|
|
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: ListAgentTasks :many
|
|
SELECT * FROM agent_task_queue
|
|
WHERE agent_id = $1
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: CreateAgentTask :one
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, trigger_comment_id)
|
|
VALUES ($1, $2, $3, 'queued', $4, sqlc.narg(trigger_comment_id))
|
|
RETURNING *;
|
|
|
|
-- name: CancelAgentTasksByIssue :exec
|
|
UPDATE agent_task_queue
|
|
SET status = 'cancelled'
|
|
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
|
|
|
-- name: CancelAgentTasksByAgent :exec
|
|
UPDATE agent_task_queue
|
|
SET status = 'cancelled'
|
|
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
|
|
|
-- name: GetAgentTask :one
|
|
SELECT * FROM agent_task_queue
|
|
WHERE id = $1;
|
|
|
|
-- name: ClaimAgentTask :one
|
|
-- Claims the next queued task for an agent, enforcing per-(issue, agent) serialization:
|
|
-- a task is only claimable when no other task for the same issue AND same agent is
|
|
-- already dispatched or running. This allows different agents to work on the same
|
|
-- issue in parallel while preventing a single agent from running duplicate tasks.
|
|
-- Chat tasks (issue_id IS NULL) use chat_session_id for serialization instead.
|
|
UPDATE agent_task_queue
|
|
SET status = 'dispatched', dispatched_at = now()
|
|
WHERE id = (
|
|
SELECT atq.id FROM agent_task_queue atq
|
|
WHERE atq.agent_id = $1 AND atq.status = 'queued'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM agent_task_queue active
|
|
WHERE active.agent_id = atq.agent_id
|
|
AND active.status IN ('dispatched', 'running')
|
|
AND (
|
|
(atq.issue_id IS NOT NULL AND active.issue_id = atq.issue_id)
|
|
OR (atq.chat_session_id IS NOT NULL AND active.chat_session_id = atq.chat_session_id)
|
|
)
|
|
)
|
|
ORDER BY atq.priority DESC, atq.created_at ASC
|
|
LIMIT 1
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: StartAgentTask :one
|
|
UPDATE agent_task_queue
|
|
SET status = 'running', started_at = now()
|
|
WHERE id = $1 AND status = 'dispatched'
|
|
RETURNING *;
|
|
|
|
-- name: CompleteAgentTask :one
|
|
UPDATE agent_task_queue
|
|
SET status = 'completed', completed_at = now(), result = $2, session_id = $3, work_dir = $4
|
|
WHERE id = $1 AND status = 'running'
|
|
RETURNING *;
|
|
|
|
-- name: GetLastTaskSession :one
|
|
-- Returns the session_id and work_dir from the most recent completed task
|
|
-- for a given (agent_id, issue_id) pair, used for session resumption.
|
|
SELECT session_id, work_dir FROM agent_task_queue
|
|
WHERE agent_id = $1 AND issue_id = $2 AND status = 'completed' AND session_id IS NOT NULL
|
|
ORDER BY completed_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: FailAgentTask :one
|
|
-- Marks a task as failed. session_id and work_dir are merged via COALESCE so
|
|
-- if the agent already established a real session before failing (e.g. it
|
|
-- crashed mid-conversation, was cancelled, or hit a tool error) the resume
|
|
-- pointer is preserved on the task row. The next chat task can then fall
|
|
-- back to GetLastChatTaskSession and continue the conversation instead of
|
|
-- silently starting over.
|
|
UPDATE agent_task_queue
|
|
SET status = 'failed',
|
|
completed_at = now(),
|
|
error = $2,
|
|
session_id = COALESCE(sqlc.narg('session_id'), session_id),
|
|
work_dir = COALESCE(sqlc.narg('work_dir'), work_dir)
|
|
WHERE id = $1 AND status IN ('dispatched', 'running')
|
|
RETURNING *;
|
|
|
|
-- name: FailStaleTasks :many
|
|
-- Fails tasks stuck in dispatched/running beyond the given thresholds.
|
|
-- Handles cases where the daemon is alive but the task is orphaned
|
|
-- (e.g. agent process hung, daemon failed to report completion).
|
|
UPDATE agent_task_queue
|
|
SET status = 'failed', completed_at = now(), error = 'task timed out'
|
|
WHERE (status = 'dispatched' AND dispatched_at < now() - make_interval(secs => @dispatch_timeout_secs::double precision))
|
|
OR (status = 'running' AND started_at < now() - make_interval(secs => @running_timeout_secs::double precision))
|
|
RETURNING id, agent_id, issue_id;
|
|
|
|
-- name: CancelAgentTask :one
|
|
UPDATE agent_task_queue
|
|
SET status = 'cancelled', completed_at = now()
|
|
WHERE id = $1 AND status IN ('queued', 'dispatched', 'running')
|
|
RETURNING *;
|
|
|
|
-- name: CountRunningTasks :one
|
|
SELECT count(*) FROM agent_task_queue
|
|
WHERE agent_id = $1 AND status IN ('dispatched', 'running');
|
|
|
|
-- name: HasActiveTaskForIssue :one
|
|
-- Returns true if there is any queued, dispatched, or running task for the issue.
|
|
SELECT count(*) > 0 AS has_active FROM agent_task_queue
|
|
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
|
|
|
-- name: HasPendingTaskForIssue :one
|
|
-- Returns true if there is a queued or dispatched (but not yet running) task for the issue.
|
|
-- Used by the coalescing queue: allow enqueue when a task is running (so
|
|
-- the agent picks up new comments on the next cycle) but skip if a pending
|
|
-- task already exists (natural dedup).
|
|
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
|
WHERE issue_id = $1 AND status IN ('queued', 'dispatched');
|
|
|
|
-- name: HasPendingTaskForIssueAndAgent :one
|
|
-- Returns true if a specific agent already has a queued or dispatched task
|
|
-- for the given issue. Used by @mention trigger dedup.
|
|
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched');
|
|
|
|
-- name: ListPendingTasksByRuntime :many
|
|
SELECT * FROM agent_task_queue
|
|
WHERE runtime_id = $1 AND status IN ('queued', 'dispatched')
|
|
ORDER BY priority DESC, created_at ASC;
|
|
|
|
-- name: ListActiveTasksByIssue :many
|
|
SELECT * FROM agent_task_queue
|
|
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: ListTasksByIssue :many
|
|
SELECT * FROM agent_task_queue
|
|
WHERE issue_id = $1
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: UpdateAgentStatus :one
|
|
UPDATE agent SET status = $2, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|