Skip to content
Open
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
12 changes: 12 additions & 0 deletions packages/views/runtimes/components/provider-logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ function HermesLogo({ className }: { className: string }) {
);
}

// Ollama — official llama silhouette, monochrome, sourced from
// github.com/ollama/ollama brand assets (logo.svg, simplified path).
function OllamaLogo({ className }: { className: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M7.755 0c-.704.014-1.396.293-1.94.84-.96.962-1.244 2.376-.92 3.673-.747.59-1.318 1.43-1.61 2.413-.291.984-.281 2.025.013 2.99-.83.83-1.298 1.96-1.298 3.135 0 1.077.394 2.114 1.105 2.917-.214.787-.211 1.62.011 2.405-.222.785-.225 1.62-.011 2.407-.711.802-1.105 1.84-1.105 2.916 0 .073.002.145.005.218H22.99c.003-.073.005-.145.005-.218 0-1.076-.394-2.114-1.105-2.916.222-.787.219-1.62-.011-2.407.222-.786.225-1.618.011-2.405.711-.803 1.105-1.84 1.105-2.917 0-1.175-.468-2.305-1.298-3.135.294-.965.304-2.006.013-2.99-.292-.984-.863-1.823-1.61-2.413.324-1.297.04-2.711-.92-3.673-1.087-1.092-2.795-1.122-3.91-.114-.997-.585-2.215-.59-3.218-.012-1.003-.578-2.221-.573-3.218.012C9.71.297 8.86-.012 8 0a2.92 2.92 0 0 0-.245 0Z" />
</svg>
);
}

export function ProviderLogo({
provider,
className = "h-4 w-4",
Expand All @@ -91,6 +101,8 @@ export function ProviderLogo({
return <OpenClawLogo className={className} />;
case "hermes":
return <HermesLogo className={className} />;
case "ollama":
return <OllamaLogo className={className} />;
default:
return <Monitor className={className} />;
}
Expand Down
66 changes: 63 additions & 3 deletions server/internal/daemon/config.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package daemon

import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/multica-ai/multica/server/pkg/agent"
)

const (
Expand All @@ -21,8 +25,9 @@ const (
DefaultHealthPort = 19514
DefaultMaxConcurrentTasks = 20
DefaultGCInterval = 1 * time.Hour
DefaultGCTTL = 5 * 24 * time.Hour // 5 days
DefaultGCTTL = 5 * 24 * time.Hour // 5 days
DefaultGCOrphanTTL = 30 * 24 * time.Hour // 30 days
ollamaProbeTimeout = 2 * time.Second
)

// Config holds all daemon configuration.
Expand All @@ -34,7 +39,7 @@ type Config struct {
CLIVersion string // multica CLI version (e.g. "0.1.13")
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
Profile string // profile name (empty = default)
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini, ollama
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
KeepEnvAfterTask bool // preserve env after task for debugging
HealthPort int // local HTTP port for health checks (default: 19514)
Expand Down Expand Up @@ -121,8 +126,42 @@ func LoadConfig(overrides Overrides) (Config, error) {
Model: strings.TrimSpace(os.Getenv("MULTICA_GEMINI_MODEL")),
}
}

// Ollama is HTTP-based, not a CLI — probe the /api/tags endpoint
// rather than looking for a binary on PATH. The resolved base URL is
// stored in AgentEntry.Path and read back by the ollama backend.
ollamaHost := strings.TrimRight(envOrDefault("MULTICA_OLLAMA_HOST", agent.DefaultOllamaHost), "/")
ollamaModel := strings.TrimSpace(os.Getenv("MULTICA_OLLAMA_MODEL"))
if ollamaModel != "" && probeOllamaReachable(ollamaHost) {
agents["ollama"] = AgentEntry{
Path: ollamaHost,
Model: ollamaModel,
}
// Best-effort capability check — warn if the model doesn't
// advertise tool support. Don't block registration: the user
// may intentionally want a chat-only model, and an /api/show
// failure shouldn't stop the daemon from starting. Scope the
// cancel to the narrowest block so it matches the pattern in
// probeOllamaReachable rather than hanging around for the rest
// of LoadConfig.
capCtx, capCancel := context.WithTimeout(context.Background(), ollamaProbeTimeout)
supports, capErr := agent.OllamaModelSupportsTools(capCtx, ollamaHost, ollamaModel)
capCancel()
if capErr == nil && !supports {
fmt.Fprintf(os.Stderr,
"WARNING: Ollama model %q does not declare 'tools' capability. "+
"Issue-assignment tasks will degrade to text-only responses. "+
"Use a tool-capable model (e.g. gemma4:latest, qwen2.5-coder:1.5b, llama3.1).\n",
ollamaModel)
}
}

if len(agents) == 0 {
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, or gemini and ensure it is on PATH")
return Config{}, fmt.Errorf(
"no agent runtime available: install claude/codex/opencode/openclaw/hermes/gemini on PATH, " +
"or set MULTICA_OLLAMA_MODEL with a reachable Ollama server " +
"(MULTICA_OLLAMA_HOST, default " + agent.DefaultOllamaHost + ")",
)
}

// Host info
Expand Down Expand Up @@ -279,3 +318,24 @@ func NormalizeServerBaseURL(raw string) (string, error) {
u.Fragment = ""
return strings.TrimRight(u.String(), "/"), nil
}

// probeOllamaReachable returns true if an Ollama server responds 2xx to
// GET {baseURL}/api/tags within ollamaProbeTimeout. The call is cheap
// (lists local model tags) and exists on every Ollama version.
func probeOllamaReachable(baseURL string) bool {
if baseURL == "" {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), ollamaProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/tags", nil)
if err != nil {
return false
}
resp, err := (&http.Client{}).Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300
}
2 changes: 1 addition & 1 deletion server/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ func (d *Daemon) providerToRuntimeMap() map[string]string {
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) (*RegisterResponse, error) {
var runtimes []map[string]string
for name, entry := range d.cfg.Agents {
version, err := agent.DetectVersion(ctx, entry.Path)
version, err := agent.DetectVersion(ctx, name, entry.Path)
if err != nil {
d.logger.Warn("skip registering runtime", "name", name, "error", err)
continue
Expand Down
5 changes: 4 additions & 1 deletion server/internal/daemon/execenv/runtime_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import (
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/)
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/)
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
// For Ollama: writes {workDir}/AGENTS.md (Ollama has no native config file convention;
// kept for parity so a human inspecting the
// workdir sees the same context)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
content := buildMetaSkillContent(provider, ctx)

switch provider {
case "claude":
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
case "codex", "opencode", "openclaw":
case "codex", "opencode", "openclaw", "ollama":
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
case "gemini":
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
Expand Down
20 changes: 15 additions & 5 deletions server/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,13 @@ type Result struct {

// Config configures a Backend instance.
type Config struct {
ExecutablePath string // path to CLI binary (claude, codex, opencode, openclaw, hermes, or gemini)
ExecutablePath string // path to CLI binary (claude, codex, opencode, openclaw, hermes, gemini), or base URL for HTTP-based providers (ollama)
Env map[string]string // extra environment variables
Logger *slog.Logger
}

// New creates a Backend for the given agent type.
// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini".
// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini", "ollama".
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
Expand All @@ -107,12 +107,22 @@ func New(agentType string, cfg Config) (Backend, error) {
return &hermesBackend{cfg: cfg}, nil
case "gemini":
return &geminiBackend{cfg: cfg}, nil
case "ollama":
return &ollamaBackend{cfg: cfg}, nil
default:
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini)", agentType)
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini, ollama)", agentType)
}
}

// DetectVersion runs the agent CLI with --version and returns the output.
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
// DetectVersion returns the version string for the given agent.
//
// For CLI-based providers (claude, codex, opencode, openclaw, hermes, gemini)
// this runs `<executablePath> --version`. For HTTP-based providers (ollama)
// the executablePath is the base URL and version is fetched via the
// provider's HTTP API.
func DetectVersion(ctx context.Context, agentType, executablePath string) (string, error) {
if agentType == "ollama" {
return detectOllamaVersion(ctx, executablePath)
}
return detectCLIVersion(ctx, executablePath)
}
2 changes: 1 addition & 1 deletion server/pkg/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func TestNewDefaultsLogger(t *testing.T) {

func TestDetectVersionFailsForMissingBinary(t *testing.T) {
t.Parallel()
_, err := DetectVersion(context.Background(), "/nonexistent/binary")
_, err := DetectVersion(context.Background(), "claude", "/nonexistent/binary")
if err == nil {
t.Fatal("expected error for missing binary")
}
Expand Down
Loading
Loading