diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84d458c3..596836aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,9 @@ description and keep ownership on the side listed here. - `runtime_id` chooses the concrete paired runtime/device/sandbox that will receive a run. It is a routing handle, not agent configuration. - `agent_kind` chooses the daemon-side engine (`claude_code`, `codex`, - `pi`, `opencode`). It is interpreted only by `parsar-daemon`. + `pi`, `opencode`, `deepseek_harness`). It is interpreted only by + `parsar-daemon`. The wire value is `snake_case`; the web layer normalizes + dashes and aliases in `apps/web/src/lib/agent-view-model.ts`. - Placement labels such as local device, cloud sandbox, and external agent are UI/product concepts. Do not branch business logic on display copy. Derive placement from typed runtime/provider/config fields in one shared @@ -160,6 +162,26 @@ description and keep ownership on the side listed here. - Adapter-specific state directories must be derived from `AgentStateKey` under `~/.parsar/`; never use the repo checkout, container image working directory, or the process CWD as hidden state. +- Engine discovery is one table: `apps/parsar-daemon/internal/cli/agent_cli.go` + owns the per-engine capability descriptor, version probe, and operator + preflight output. Add an engine by extending that table, not by copying + another probe-and-report block. +- The heartbeat capability descriptor states what the adapter actually + delivers. An engine whose only supported automation surface is one-shot + (no event stream, token accounting, resume flag, or approval channel — + `deepseek_harness` today) advertises none of them and must not synthesize a + `done` session id, a fake usage total, or an auto-approved permission. +- Conversation continuity for an engine that advertises + `Capabilities.Resume=false` is the server's job, not the adapter's: the + connector folds a bounded transcript tail into the system-prompt slot + (`server/internal/connector/agentdaemon/history_injection.go`). Gate that + behaviour on the device's live descriptor rather than a list of engine + names, and keep it bounded — these engines re-send the whole prompt every + turn with no cache reuse. Adapters must not invent their own history. +- When an adapter materializes engine config per prompt, scope that file to + the run and delete it on cleanup if the engine watches its config layers + for live edits. A shared, rewritten-in-place config would re-apply one + run's model onto another run of the same conversation. ### Human interaction lifecycle diff --git a/Dockerfile b/Dockerfile index 028a6031..0ecbfe40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,8 +37,12 @@ ARG NODE_VERSION=22-alpine ARG GO_VERSION=1.25-bookworm ARG RUNTIME_BASE=debian:bookworm-slim +# Full builder references so a host that cannot reach docker.io can point +# them at a mirror, the way RUNTIME_BASE already allows for the final stage. +ARG NODE_IMAGE=node:${NODE_VERSION} +ARG GO_IMAGE=golang:${GO_VERSION} -FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS web-builder +FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder ENV PNPM_HOME=/pnpm ENV PATH=/pnpm:$PATH RUN corepack enable && corepack prepare pnpm@10.30.3 --activate @@ -65,9 +69,14 @@ RUN pnpm --filter @parsar/web build # minimal runtime. trimpath strips build-host file paths from the # binary (defence-in-depth against operator info leaks). ############################################################################### -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS go-builder +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-builder ARG TARGETOS ARG TARGETARCH +# Overridable module proxy: the default matches Go's own, but a build host +# that cannot reach proxy.golang.org can point at a mirror +# (--build-arg GOPROXY=https://goproxy.cn,direct). +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=${GOPROXY} WORKDIR /src # Module graph first, source second — keeps `go mod download` cacheable. diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/export_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/export_test.go new file mode 100644 index 00000000..c485a92d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/export_test.go @@ -0,0 +1,49 @@ +package deepseekharness + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type SessionConfigForTest struct { + Binary string + ExtraArgs []string + KillTimeout time.Duration +} + +func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { + return newSession(ctx, req, out, sessionConfig{ + binary: cfg.Binary, + extraArgs: cfg.ExtraArgs, + killTimeout: cfg.KillTimeout, + }) +} + +type Translator translator + +func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } + +func (t *Translator) AppendLine(line string) { (*translator)(t).appendLine(line) } + +func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) +} + +func RenderPatchForTest(raw any, model, provider string) ([]byte, error) { + cfg, hasProvider, err := normaliseProvider(raw) + if err != nil { + return nil, err + } + return renderPatch(cfg, hasProvider, model, provider) +} + +func ResolveHomeForTest(agentStateKey, conversationID, runID string) (string, error) { + return resolveHome(agentStateKey, conversationID, runID) +} + +const ( + HomeEnvVarForTest = dshHomeEnvVar + ManagedRouteForTest = managedRoute +) diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/options.go b/apps/parsar-daemon/internal/agent/deepseekharness/options.go new file mode 100644 index 00000000..41e64071 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/options.go @@ -0,0 +1,175 @@ +package deepseekharness + +import ( + "fmt" + "maps" + "os" + "path/filepath" + "sort" + "strings" +) + +// BuildResult is the dsh CLI launch plan for one prompt. Cleanup is +// always non-nil so callers can defer it blindly. +type BuildResult struct { + Args []string + Env []string + WorkDir string + Cleanup func() +} + +// StateKeys carries the identifiers the adapter derives its DSH_HOME and +// per-run patch overlay from. +type StateKeys struct { + AgentStateKey string + ConversationID string + RunID string +} + +// BuildArgs translates the daemon prompt_request into a +// `dsh --profile headless ` invocation. +func BuildArgs(prompt, workDir string, opts map[string]any, state StateKeys) (BuildResult, error) { + result := BuildResult{Cleanup: func() {}} + + resolvedWorkDir, err := resolveWorkDir(workDir) + if err != nil { + return result, err + } + + // dsh headless takes the task as one positional argument and offers + // no --system-prompt flag, so an injected system prompt is prepended + // to the task text (same as the opencode adapter). + promptText, err := buildPrompt(prompt, opts) + if err != nil { + return result, err + } + + provider, hasProvider, err := normaliseProvider(opts["dsh_provider"]) + if err != nil { + return result, err + } + home, err := resolveHome(state.AgentStateKey, state.ConversationID, state.RunID) + if err != nil { + return result, err + } + if err := os.MkdirAll(home, 0o700); err != nil { + return result, fmt.Errorf("deepseekharness: mkdir dsh home %s: %w", home, err) + } + patchPath, cleanup, err := writeRuntimePatch(home, state.RunID, provider, hasProvider, + stringOpt(opts, "model"), stringOpt(opts, "provider")) + if err != nil { + return result, err + } + + args := []string{"--profile", headlessProfile, "--patch", patchPath} + // The launcher consumes one `--`, so everything after it reaches the + // headless app verbatim — a task starting with a dash included. + args = append(args, "--", promptText) + + envOpt, err := envMap(opts["env"]) + if err != nil { + cleanup() + return result, err + } + // Assigned after the caller's env is copied so agent_options cannot + // redirect the state root, widen the file-effect boundary, or turn + // telemetry back on for an unattended run. + envOpt[dshHomeEnvVar] = home + envOpt[dshPermissionModeEnvVar] = sandboxPermissionMode + envOpt[dshTelemetryDisabledEnvVar] = "1" + env, err := buildEnv(envOpt) + if err != nil { + cleanup() + return result, err + } + + result.Args = args + result.Env = env + result.WorkDir = resolvedWorkDir + result.Cleanup = cleanup + return result, nil +} + +func resolveWorkDir(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", nil + } + var abs string + switch { + case strings.HasPrefix(trimmed, "~/"): + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("deepseekharness: resolve home dir: %w", err) + } + abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + case filepath.IsAbs(trimmed): + abs = trimmed + default: + return "", fmt.Errorf("deepseekharness: work_dir must be absolute or start with ~/, got %q", trimmed) + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", fmt.Errorf("deepseekharness: mkdir work_dir %s: %w", abs, err) + } + return abs, nil +} + +func buildPrompt(prompt string, opts map[string]any) (string, error) { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return "", fmt.Errorf("deepseekharness: empty prompt") + } + systemPrompt := stringOpt(opts, "system_prompt") + if override := stringOpt(opts, "override_system_prompt"); override != "" { + systemPrompt = override + } + if systemPrompt == "" { + return prompt, nil + } + return systemPrompt + "\n\n" + prompt, nil +} + +func envMap(raw any) (map[string]any, error) { + if raw == nil { + return map[string]any{}, nil + } + m, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("deepseekharness.BuildArgs: env must be object, got %T", raw) + } + out := make(map[string]any, len(m)+1) + maps.Copy(out, m) + return out, nil +} + +func buildEnv(envOpt map[string]any) ([]string, error) { + env := make([]string, 0, len(envOpt)) + keys := make([]string, 0, len(envOpt)) + for k := range envOpt { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s, ok := envOpt[k].(string) + if !ok { + return nil, fmt.Errorf("deepseekharness.BuildArgs: env[%q] must be string, got %T", k, envOpt[k]) + } + env = append(env, k+"="+s) + } + return env, nil +} + +func stringOpt(opts map[string]any, key string) string { + if opts == nil { + return "" + } + v, ok := opts[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/options_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/options_test.go new file mode 100644 index 00000000..47851d58 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/options_test.go @@ -0,0 +1,276 @@ +package deepseekharness_test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" +) + +func stateKeys(runID string) deepseekharness.StateKeys { + return deepseekharness.StateKeys{AgentStateKey: "conv1/agent1/deepseek_harness", RunID: runID} +} + +func TestBuildArgsUsesHeadlessProfileAndTaskLast(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", os.TempDir(), nil, stateKeys("run-1")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + if !containsPair(res.Args, "--profile", "headless") { + t.Fatalf("args missing --profile headless: %v", res.Args) + } + // The launcher consumes one `--`, so the task must be the final arg + // directly behind it or a task starting with a dash is parsed as a + // launcher flag. + n := len(res.Args) + if n < 2 || res.Args[n-2] != "--" || res.Args[n-1] != "hello" { + t.Fatalf("expected args to end with -- hello, got %v", res.Args) + } + if res.WorkDir != os.TempDir() { + t.Fatalf("WorkDir = %q, want %q", res.WorkDir, os.TempDir()) + } +} + +func TestBuildArgsWritesRunScopedPatchCleanedUp(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", nil, stateKeys("run-patch")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + patchPath := flagValue(res.Args, "--patch") + if patchPath == "" { + t.Fatalf("args missing --patch: %v", res.Args) + } + body, err := os.ReadFile(patchPath) + if err != nil { + t.Fatalf("read patch: %v", err) + } + // A daemon run has no approval answerer, so the overlay must always + // select the unattended permission preset even without a managed model. + if !strings.Contains(string(body), "id: permission") || !strings.Contains(string(body), "defaultPreset: parsar-unattended") { + t.Fatalf("patch missing permission preset override:\n%s", body) + } + res.Cleanup() + if _, err := os.Stat(patchPath); !os.IsNotExist(err) { + t.Fatalf("patch file must be removed by Cleanup, stat err = %v", err) + } +} + +func TestBuildArgsPinsDshHomeUnderParsarRoot(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "env": map[string]any{"DSH_HOME": "/tmp/attacker"}, + }, stateKeys("run-home")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + + home := envValue(res.Env, deepseekharness.HomeEnvVarForTest) + if !strings.HasPrefix(home, root) { + t.Fatalf("DSH_HOME = %q, want a path under %q", home, root) + } + if strings.Contains(home, "attacker") { + t.Fatalf("adapter DSH_HOME must win over agent_options env: %q", home) + } + info, err := os.Stat(home) + if err != nil || !info.IsDir() { + t.Fatalf("DSH_HOME %q not created: err=%v", home, err) + } +} + +func TestBuildArgsSystemPromptPrependsToTask(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "system_prompt": "be terse", + }, stateKeys("run-sys")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + task := res.Args[len(res.Args)-1] + if task != "be terse\n\nhello" { + t.Fatalf("task = %q, want system prompt prepended", task) + } +} + +func TestBuildArgsOverrideSystemPromptWins(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "system_prompt": "be terse", + "override_system_prompt": "you are root", + }, stateKeys("run-override")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + task := res.Args[len(res.Args)-1] + if task != "you are root\n\nhello" { + t.Fatalf("task = %q, want override prepended", task) + } +} + +func TestBuildArgsKeepsSecretsOffArgv(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "dsh_provider": map[string]any{ + "base_url": "https://gw.example/v1", + "api": "openai-completions", + "api_key_env": "PARSAR_DSH_API_KEY", + "model": "deepseek-v4", + }, + "env": map[string]any{"PARSAR_DSH_API_KEY": "sk-secret"}, + }, stateKeys("run-secret")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if slices.Contains(res.Args, "sk-secret") { + t.Fatalf("api key must not reach argv: %v", res.Args) + } + if envValue(res.Env, "PARSAR_DSH_API_KEY") != "sk-secret" { + t.Fatalf("api key must ride the environment: %v", res.Env) + } + body, err := os.ReadFile(flagValue(res.Args, "--patch")) + if err != nil { + t.Fatalf("read patch: %v", err) + } + if strings.Contains(string(body), "sk-secret") { + t.Fatalf("patch overlay must reference the env var, not the key:\n%s", body) + } +} + +// The telemetry opt-out and the file-effect boundary are adapter policy for +// an unattended run, so agent_options must not be able to widen either. +func TestBuildArgsForcesTelemetryOptOutAndPermissionMode(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + res, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "env": map[string]any{ + "DSH_TELEMETRY_DISABLED": "", + "DSH_PERMISSION_MODE": "danger-full-access", + }, + }, stateKeys("run-telemetry")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + if got := envValue(res.Env, "DSH_TELEMETRY_DISABLED"); got != "1" { + t.Fatalf("DSH_TELEMETRY_DISABLED = %q, want the forced opt-out; env=%v", got, res.Env) + } + if got := envValue(res.Env, "DSH_PERMISSION_MODE"); got != "workspace-write" { + t.Fatalf("DSH_PERMISSION_MODE = %q, want workspace-write; env=%v", got, res.Env) + } + // A single entry per key: cmd.Env resolves duplicates to the last one, + // so a caller copy left in place could still win. + if n := envCount(res.Env, "DSH_PERMISSION_MODE"); n != 1 { + t.Fatalf("DSH_PERMISSION_MODE appears %d times, want exactly 1: %v", n, res.Env) + } +} + +func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + _, err := deepseekharness.BuildArgs("hello", "./relative", nil, stateKeys("run-rel")) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) + } +} + +func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") + res, err := deepseekharness.BuildArgs("hello", target, nil, stateKeys("run-mkdir")) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + defer res.Cleanup() + info, err := os.Stat(target) + if err != nil || !info.IsDir() { + t.Fatalf("work dir %q not created: err=%v", target, err) + } +} + +func TestBuildArgsRejectsEmptyPrompt(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + _, err := deepseekharness.BuildArgs(" ", "", nil, stateKeys("run-empty")) + if err == nil || !strings.Contains(err.Error(), "prompt") { + t.Fatalf("BuildArgs empty prompt err = %v, want prompt error", err) + } +} + +func TestBuildArgsRejectsBadEnvShape(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + _, err := deepseekharness.BuildArgs("hello", "", map[string]any{ + "env": map[string]any{"K": 1}, + }, stateKeys("run-badenv")) + if err == nil || !strings.Contains(err.Error(), "env") { + t.Fatalf("BuildArgs env err = %v, want env shape error", err) + } +} + +func TestResolveHomeIsStablePerStateKey(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + first, err := deepseekharness.ResolveHomeForTest("conv1/agent1/deepseek_harness", "conv1", "run-a") + if err != nil { + t.Fatalf("ResolveHomeForTest: %v", err) + } + second, err := deepseekharness.ResolveHomeForTest("conv1/agent1/deepseek_harness", "conv1", "run-b") + if err != nil { + t.Fatalf("ResolveHomeForTest: %v", err) + } + if first != second { + t.Fatalf("home must be stable across runs of one state key: %q vs %q", first, second) + } + traversal, err := deepseekharness.ResolveHomeForTest("../../etc/passwd", "", "run-c") + if err != nil { + t.Fatalf("ResolveHomeForTest traversal: %v", err) + } + if strings.Contains(traversal, "..") { + t.Fatalf("state key must not escape the root: %q", traversal) + } +} + +func containsPair(args []string, flag, value string) bool { + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return true + } + } + return false +} + +func flagValue(args []string, flag string) string { + for i, a := range args { + if a == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +func envCount(env []string, key string) int { + prefix := key + "=" + count := 0 + for _, item := range env { + if strings.HasPrefix(item, prefix) { + count++ + } + } + return count +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if v, ok := strings.CutPrefix(item, prefix); ok { + return v + } + } + return "" +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/parser.go b/apps/parsar-daemon/internal/agent/deepseekharness/parser.go new file mode 100644 index 00000000..ead784a1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/parser.go @@ -0,0 +1,86 @@ +package deepseekharness + +import ( + "fmt" + "strings" + "sync/atomic" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// usageProvider tags the done frame so downstream usage attribution can +// tell a dsh run apart even though the CLI reports no token counts. +const usageProvider = "deepseek-harness" + +// translator collects the headless run's stdout. `dsh --profile headless` +// prints the final assistant text and nothing else — no event stream — so +// the whole answer is buffered and emitted as one delta before done. +type translator struct { + runID string + seq atomic.Uint64 + + answer strings.Builder +} + +func newTranslator(runID string) *translator { return &translator{runID: runID} } + +func (t *translator) appendLine(line string) { + if t.answer.Len() > 0 { + t.answer.WriteByte('\n') + } + t.answer.WriteString(line) +} + +func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { + var envs []proto.Envelope + content := strings.TrimSpace(t.answer.String()) + if content != "" { + if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ + Delta: content, + Sequence: t.seq.Add(1), + }); err == nil { + envs = append(envs, env) + } + } + if waitErr != nil || cancelled { + if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{ + Error: terminalErrorMessage(waitErr, stderr, cancelled), + }); err == nil { + envs = append(envs, env) + } + } + usage := proto.Usage{Provider: usageProvider} + if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{ + Content: content, + Transcript: content, + Usage: usage, + Metadata: map[string]any{"connector_path": "dsh_headless"}, + }); err == nil { + envs = append(envs, env) + } + return envs +} + +// terminalErrorMessage folds the exit status and stderr into one message. +// dsh exits non-zero for any turn that did not complete and writes the +// durable error code plus message to stderr, so stderr is the useful part. +func terminalErrorMessage(waitErr error, stderr string, cancelled bool) string { + if cancelled { + return "deepseek-harness: cancelled" + } + msg := "deepseek-harness: dsh exited without completing the turn" + if waitErr != nil { + msg = fmt.Sprintf("deepseek-harness: dsh exited: %v", waitErr) + } + if trimmed := strings.TrimSpace(stderr); trimmed != "" { + msg += ": " + truncate(trimmed, 400) + } + return msg +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/parser_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/parser_test.go new file mode 100644 index 00000000..6f8281a5 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/parser_test.go @@ -0,0 +1,94 @@ +package deepseekharness_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func decodePayload[T any](t *testing.T, env proto.Envelope) T { + t.Helper() + var out T + if err := json.Unmarshal(env.Payload, &out); err != nil { + t.Fatalf("decode %s payload: %v", env.Type, err) + } + return out +} + +func TestTerminalEnvelopesSuccessEmitsDeltaThenDone(t *testing.T) { + tr := deepseekharness.NewTranslatorForTest("run-1") + tr.AppendLine("first line") + tr.AppendLine("second line") + + envs := tr.TerminalEnvelopes(nil, "", false) + if len(envs) != 2 { + t.Fatalf("expected delta+done, got %d: %v", len(envs), envTypes(envs)) + } + if envs[0].Type != proto.TypeDelta || envs[1].Type != proto.TypeDone { + t.Fatalf("types = %v", envTypes(envs)) + } + delta := decodePayload[proto.DeltaPayload](t, envs[0]) + if delta.Delta != "first line\nsecond line" { + t.Fatalf("delta = %q", delta.Delta) + } + done := decodePayload[proto.DonePayload](t, envs[1]) + if done.Content != "first line\nsecond line" { + t.Fatalf("done content = %q", done.Content) + } + // dsh headless creates a fresh session per run and prints no session + // id, so the server must not be handed a resume handle. + if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { + t.Fatalf("done metadata must carry no session id: %#v", done.Metadata) + } + if done.Usage.Provider != "deepseek-harness" { + t.Fatalf("done usage = %#v", done.Usage) + } + for _, env := range envs { + if env.ID != "run-1" { + t.Fatalf("env %s ID = %q, want run-1", env.Type, env.ID) + } + } +} + +func TestTerminalEnvelopesFailureFoldsStderr(t *testing.T) { + tr := deepseekharness.NewTranslatorForTest("run-2") + envs := tr.TerminalEnvelopes(errors.New("exit status 1"), "MODEL_ERROR: upstream refused", false) + types := envTypes(envs) + if len(envs) != 2 || envs[0].Type != proto.TypeError || envs[1].Type != proto.TypeDone { + t.Fatalf("types = %v, want error+done", types) + } + payload := decodePayload[proto.ErrorPayload](t, envs[0]) + if !strings.Contains(payload.Error, "exit status 1") || !strings.Contains(payload.Error, "upstream refused") { + t.Fatalf("error payload = %q", payload.Error) + } +} + +func TestTerminalEnvelopesCancelledReportsCancellation(t *testing.T) { + tr := deepseekharness.NewTranslatorForTest("run-3") + tr.AppendLine("partial") + envs := tr.TerminalEnvelopes(errors.New("signal: terminated"), "", true) + if envs[len(envs)-1].Type != proto.TypeDone { + t.Fatalf("last env = %v, want done", envTypes(envs)) + } + var errPayload proto.ErrorPayload + for _, env := range envs { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "cancelled") { + t.Fatalf("error payload = %q, want cancelled", errPayload.Error) + } +} + +func envTypes(envs []proto.Envelope) []string { + out := make([]string, len(envs)) + for i, env := range envs { + out[i] = env.Type + } + return out +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go new file mode 100644 index 00000000..6fe95e10 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config.go @@ -0,0 +1,277 @@ +package deepseekharness + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "gopkg.in/yaml.v3" +) + +const ( + // dshHomeEnvVar is dsh's only override for its state root: it holds + // profiles/, sessions/, settings.yaml and .credentials.yaml. Parsar + // pins it under ~/.parsar so a run never writes into the repo + // checkout or the daemon's CWD. + dshHomeEnvVar = "DSH_HOME" + + // dshPermissionModeEnvVar drives dsh's sandbox-policy row. Pinning it + // keeps the file-effect boundary independent of what a developer-preview + // release ships as its default. + dshPermissionModeEnvVar = "DSH_PERMISSION_MODE" + + // sandboxPermissionMode keeps bash and filesystem mutations inside the + // run's workspace. Parsar runs dsh unattended, so a wider mode would + // let a turn write outside the directory the operator bound. + sandboxPermissionMode = "workspace-write" + + // unattendedPreset is the permission preset the overlay declares and + // selects: workspace-scoped writes with approval prompts off. + unattendedPreset = "parsar-unattended" + + // dshTelemetryDisabledEnvVar is dsh's authoritative hard opt-out: any + // non-empty value wins over the composed telemetry row. + dshTelemetryDisabledEnvVar = "DSH_TELEMETRY_DISABLED" + + // headlessProfile is the shipped one-shot profile (dsh-base + + // dsh-headless). It auto-initialises from the installation template + // on first use, so a fresh DSH_HOME needs no provisioning step. + headlessProfile = "headless" + + // managedRoute is the llm-pi-ai provider route the daemon + // materialises for a Parsar-managed model, mirroring the "parsar" + // slug the codex and pi adapters already use. + managedRoute = "parsar" + + // shippedProviderRoute is dsh-base's own DeepSeek route. It is the + // provider a model-only agent_options selection has to name, because + // agent-default-model.provider must match a live llm route. + shippedProviderRoute = "deepseek-official" +) + +// providerConfig is the normalised form of agent_options["dsh_provider"], +// which the server emits for a Parsar-managed model. +type providerConfig struct { + Name string + BaseURL string + API string + APIKeyEnv string + Model string + Headers map[string]string +} + +type patchRow struct { + ID string `yaml:"id"` + Config any `yaml:"config"` +} + +type piAiConfig struct { + Providers map[string]piAiRoute `yaml:"providers"` +} + +// piAiRoute is one llm-pi-ai provider profile. The field set is dsh's, not +// pi's: apiKeyEnv is a bare env-var name (pi's models.json needs a "$NAME" +// template instead), and there is no auth-header knob because the adapter +// hands the resolved key to pi-ai, whose provider owns the wire auth form. +type piAiRoute struct { + DisplayName string `yaml:"displayName,omitempty"` + APIKeyEnv string `yaml:"apiKeyEnv"` + API string `yaml:"api"` + BaseURL string `yaml:"baseURL"` + Headers map[string]string `yaml:"headers,omitempty"` + Models []piAiModel `yaml:"models"` +} + +type piAiModel struct { + ID string `yaml:"id"` +} + +type defaultModelConfig struct { + Provider string `yaml:"provider"` + Model string `yaml:"model"` +} + +// permissionConfig replaces dsh's permission-preset table. The sandbox mode +// and approval policy cannot be patched independently: dsh validates the +// composed pair against this table (an unmatched pair fails boot with +// "match no preset") and re-pins both knobs from defaultPreset every time a +// session is created, so the unattended pairing has to arrive as a preset. +type permissionConfig struct { + Presets map[string]permissionPreset `yaml:"presets"` + DefaultPreset string `yaml:"defaultPreset"` +} + +type permissionPreset struct { + Sandbox string `yaml:"sandbox"` + Approval string `yaml:"approval"` + Name string `yaml:"name,omitempty"` + Description string `yaml:"description,omitempty"` +} + +// renderPatch builds the `--patch` overlay for one prompt. The overlay is +// the last layer dsh applies, and a patch replaces the addressed row's +// whole config rather than merging into it. +func renderPatch(cfg providerConfig, hasProvider bool, model, provider string) ([]byte, error) { + // A daemon run has no human answerer for dsh's approval seam, so the + // shipped `ask` policy would stall every tool call that asks. Writes + // still stay inside the run's workspace. + rows := []patchRow{{ + ID: "permission", + Config: permissionConfig{ + DefaultPreset: unattendedPreset, + Presets: map[string]permissionPreset{ + unattendedPreset: { + Sandbox: sandboxPermissionMode, + Approval: "never", + Name: "Parsar unattended", + Description: "Workspace-scoped writes with no approval prompts.", + }, + }, + }, + }} + + switch { + case hasProvider: + if err := validateProvider(cfg); err != nil { + return nil, err + } + // Replacing the llm-pi-ai row's config drops nothing: dsh-base + // mounts that adapter dormant with no config of its own, and + // routes come from whichever layer supplies them. + rows = append(rows, + patchRow{ID: "llm-pi-ai", Config: piAiConfig{Providers: map[string]piAiRoute{ + managedRoute: { + DisplayName: cfg.Name, + APIKeyEnv: cfg.APIKeyEnv, + API: cfg.API, + BaseURL: cfg.BaseURL, + Headers: cfg.Headers, + Models: []piAiModel{{ID: cfg.Model}}, + }, + }}}, + patchRow{ID: "agent-default-model", Config: defaultModelConfig{ + Provider: managedRoute, + Model: cfg.Model, + }}, + ) + case model != "": + route := provider + if route == "" { + route = shippedProviderRoute + } + rows = append(rows, patchRow{ID: "agent-default-model", Config: defaultModelConfig{ + Provider: route, + Model: model, + }}) + } + + body, err := yaml.Marshal(rows) + if err != nil { + return nil, fmt.Errorf("deepseekharness: marshal patch overlay: %w", err) + } + return body, nil +} + +func validateProvider(cfg providerConfig) error { + // A route pi-ai does not ship must declare api, baseURL and a + // non-empty model list or dsh refuses the whole profile at boot. + if strings.TrimSpace(cfg.BaseURL) == "" { + return fmt.Errorf("deepseekharness: provider base_url is required") + } + if strings.TrimSpace(cfg.API) == "" { + return fmt.Errorf("deepseekharness: provider api is required") + } + if strings.TrimSpace(cfg.APIKeyEnv) == "" { + return fmt.Errorf("deepseekharness: provider api_key_env is required") + } + if strings.TrimSpace(cfg.Model) == "" { + return fmt.Errorf("deepseekharness: provider model is required") + } + return nil +} + +// normaliseProvider flattens agent_options["dsh_provider"] into a typed +// providerConfig. hasProvider=false means the key was absent, so the run +// falls back to whatever credentials and model dsh resolves itself. +func normaliseProvider(raw any) (providerConfig, bool, error) { + if raw == nil { + return providerConfig{}, false, nil + } + m, ok := raw.(map[string]any) + if !ok { + return providerConfig{}, false, fmt.Errorf("deepseekharness: dsh_provider must be object, got %T", raw) + } + cfg := providerConfig{ + Name: stringOpt(m, "name"), + BaseURL: stringOpt(m, "base_url"), + API: stringOpt(m, "api"), + APIKeyEnv: stringOpt(m, "api_key_env"), + Model: stringOpt(m, "model"), + } + if headers, ok := m["headers"].(map[string]any); ok { + cfg.Headers = make(map[string]string, len(headers)) + for k, v := range headers { + if s, ok := v.(string); ok { + cfg.Headers[k] = s + } + } + } + return cfg, true, nil +} + +// resolveHome returns the DSH_HOME for this prompt. AgentStateKey is +// preferred because it scopes by conversation, agent and engine; +// conversation/run fallbacks exist for older callers and tests. +// +// One home is shared by every run of a state key so the profile is +// initialised once and session logs stay grouped per conversation. Two +// concurrent first runs of the same key therefore both trigger dsh's +// first-use profile initialisation; sequential turns are the normal case +// and a per-run home would re-provision the profile on every prompt. +func resolveHome(agentStateKey, conversationID, runID string) (string, error) { + root, err := paths.Root() + if err != nil { + return "", fmt.Errorf("deepseekharness: resolve state root: %w", err) + } + base := filepath.Join(root, "runtime", "deepseek-harness") + if key := strings.TrimSpace(agentStateKey); key != "" { + parts := paths.StateKeyParts(key) + if len(parts) == 0 { + return "", fmt.Errorf("deepseekharness: invalid agentStateKey %q", agentStateKey) + } + dirParts := append([]string{base, "state"}, parts...) + return filepath.Join(append(dirParts, "home")...), nil + } + if id := strings.TrimSpace(conversationID); id != "" { + return filepath.Join(base, "conv-"+id, "home"), nil + } + return filepath.Join(base, "run-"+strings.TrimSpace(runID), "home"), nil +} + +// writeRuntimePatch materialises the overlay for one run and returns its +// path plus a cleanup that removes it. The file is run-scoped rather than +// written to $DSH_HOME/cordis.patch.yml because dsh watches the home +// layer for live edits: a concurrent run of the same conversation would +// otherwise re-apply its own model onto an already-booted process. +func writeRuntimePatch(home, runID string, cfg providerConfig, hasProvider bool, model, provider string) (string, func(), error) { + noop := func() {} + body, err := renderPatch(cfg, hasProvider, model, provider) + if err != nil { + return "", noop, err + } + dir := filepath.Join(home, "patches") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", noop, fmt.Errorf("deepseekharness: mkdir patch dir %s: %w", dir, err) + } + name := paths.SafePathPart(runID) + if name == "" { + name = "run" + } + path := filepath.Join(dir, name+".patch.yml") + if err := os.WriteFile(path, body, 0o600); err != nil { + return "", noop, fmt.Errorf("deepseekharness: write %s: %w", path, err) + } + return path, func() { _ = os.Remove(path) }, nil +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/patch_config_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config_test.go new file mode 100644 index 00000000..66b432fd --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/patch_config_test.go @@ -0,0 +1,148 @@ +package deepseekharness_test + +import ( + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + "gopkg.in/yaml.v3" +) + +type patchRow struct { + ID string `yaml:"id"` + Config map[string]any `yaml:"config"` +} + +func decodeRows(t *testing.T, body []byte) map[string]map[string]any { + t.Helper() + var rows []patchRow + if err := yaml.Unmarshal(body, &rows); err != nil { + t.Fatalf("unmarshal patch: %v\n%s", err, body) + } + out := make(map[string]map[string]any, len(rows)) + for _, row := range rows { + out[row.ID] = row.Config + } + return out +} + +func TestRenderPatchManagedProviderDeclaresRoute(t *testing.T) { + body, err := deepseekharness.RenderPatchForTest(map[string]any{ + "name": "Parsar Gateway", + "base_url": "https://gw.example/v1", + "api": "openai-completions", + "api_key_env": "PARSAR_DSH_API_KEY", + "model": "deepseek-v4", + "headers": map[string]any{"X-Sub-Module": "parsar"}, + }, "deepseek-v4", "") + if err != nil { + t.Fatalf("RenderPatchForTest: %v", err) + } + rows := decodeRows(t, body) + + providers, ok := rows["llm-pi-ai"]["providers"].(map[string]any) + if !ok { + t.Fatalf("llm-pi-ai row missing providers: %#v", rows["llm-pi-ai"]) + } + route, ok := providers[deepseekharness.ManagedRouteForTest].(map[string]any) + if !ok { + t.Fatalf("missing managed route: %#v", providers) + } + // A route pi-ai does not ship is refused unless api, baseURL and a + // non-empty models list are all declared. + if route["api"] != "openai-completions" || route["baseURL"] != "https://gw.example/v1" { + t.Fatalf("route transport fields = %#v", route) + } + if route["apiKeyEnv"] != "PARSAR_DSH_API_KEY" { + t.Fatalf("route must reference the key env var: %#v", route) + } + models, ok := route["models"].([]any) + if !ok || len(models) != 1 { + t.Fatalf("route models = %#v", route["models"]) + } + + defaultModel := rows["agent-default-model"] + if defaultModel["provider"] != deepseekharness.ManagedRouteForTest || defaultModel["model"] != "deepseek-v4" { + t.Fatalf("agent-default-model = %#v", defaultModel) + } +} + +func TestRenderPatchModelOnlyKeepsShippedRoute(t *testing.T) { + body, err := deepseekharness.RenderPatchForTest(nil, "deepseek-v4-pro", "") + if err != nil { + t.Fatalf("RenderPatchForTest: %v", err) + } + rows := decodeRows(t, body) + if _, ok := rows["llm-pi-ai"]; ok { + t.Fatalf("no managed provider means no llm-pi-ai row: %#v", rows) + } + if rows["agent-default-model"]["provider"] != "deepseek-official" { + t.Fatalf("agent-default-model = %#v", rows["agent-default-model"]) + } + if rows["agent-default-model"]["model"] != "deepseek-v4-pro" { + t.Fatalf("agent-default-model = %#v", rows["agent-default-model"]) + } +} + +func TestRenderPatchWithoutModelOnlyPinsPermissionPreset(t *testing.T) { + body, err := deepseekharness.RenderPatchForTest(nil, "", "") + if err != nil { + t.Fatalf("RenderPatchForTest: %v", err) + } + rows := decodeRows(t, body) + if len(rows) != 1 { + t.Fatalf("expected only the permission row, got %#v", rows) + } + if rows["permission"]["defaultPreset"] != "parsar-unattended" { + t.Fatalf("permission row = %#v", rows["permission"]) + } +} + +// dsh validates the composed sandbox+approval pair against the preset table +// and re-pins both knobs from defaultPreset on every session creation, so +// patching the approval row alone fails boot with "match no preset". The +// pairing has to arrive as a declared, selected preset. +func TestRenderPatchDeclaresUnattendedPresetPair(t *testing.T) { + body, err := deepseekharness.RenderPatchForTest(nil, "", "") + if err != nil { + t.Fatalf("RenderPatchForTest: %v", err) + } + rows := decodeRows(t, body) + presets, ok := rows["permission"]["presets"].(map[string]any) + if !ok { + t.Fatalf("permission row missing presets: %#v", rows["permission"]) + } + preset, ok := presets["parsar-unattended"].(map[string]any) + if !ok { + t.Fatalf("missing parsar-unattended preset: %#v", presets) + } + if preset["sandbox"] != "workspace-write" || preset["approval"] != "never" { + t.Fatalf("preset pair = %#v, want workspace-write + never", preset) + } + if _, ok := rows["approval"]; ok { + t.Fatalf("the approval row must not be patched on its own: %#v", rows) + } +} + +func TestRenderPatchRejectsIncompleteProvider(t *testing.T) { + cases := map[string]map[string]any{ + "base_url": {"api": "openai-completions", "api_key_env": "K", "model": "m"}, + "api": {"base_url": "https://x/v1", "api_key_env": "K", "model": "m"}, + "api_key": {"base_url": "https://x/v1", "api": "openai-completions", "model": "m"}, + "model": {"base_url": "https://x/v1", "api": "openai-completions", "api_key_env": "K"}, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + if _, err := deepseekharness.RenderPatchForTest(raw, "", ""); err == nil { + t.Fatalf("expected rejection for incomplete provider %#v", raw) + } + }) + } +} + +func TestRenderPatchRejectsBadProviderShape(t *testing.T) { + _, err := deepseekharness.RenderPatchForTest("not-an-object", "", "") + if err == nil || !strings.Contains(err.Error(), "dsh_provider") { + t.Fatalf("err = %v, want dsh_provider shape error", err) + } +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/session.go b/apps/parsar-daemon/internal/agent/deepseekharness/session.go new file mode 100644 index 00000000..f9be53f7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/session.go @@ -0,0 +1,192 @@ +// Package deepseekharness is the agent_kind="deepseek_harness" adapter. +// It drives DeepSeek Harness through its one-shot surface, +// `dsh --profile headless `, which prints the final assistant text +// on stdout and exits non-zero for any turn that did not complete. +// +// The harness exposes no supported machine-readable event stream, resume +// flag, or approval channel for that surface, so this adapter advertises +// neither streaming, usage, resume nor permissions: one prompt is one +// fresh dsh session. +package deepseekharness + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// unsupportedOptions are agent_options Parsar renders for other engines +// that the dsh headless profile has no seam for. They are logged rather +// than dropped silently so an operator can see why a configured +// capability had no effect. +var unsupportedOptions = []string{"mcp_servers", "skills", "skill_dirs", "plugin_dirs"} + +type sessionConfig struct { + binary string + extraArgs []string + killTimeout time.Duration + logger *slog.Logger +} + +func defaultConfig() sessionConfig { + return sessionConfig{binary: defaultBinary, killTimeout: 3 * time.Second, logger: obslog.Bg()} +} + +// Factory implements agent.Factory for agent_kind="deepseek_harness". +func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + return newSession(ctx, req, out, defaultConfig()) +} + +// Session wraps a single `dsh --profile headless` subprocess. +type Session struct { + runID string + cfg sessionConfig + + proc *clirunner.Process + out chan<- proto.Envelope + + cancelCtx context.Context + + cancelOnce sync.Once + closeOutOnce sync.Once + cleanup func() + + stderrMu sync.Mutex + stderr bytes.Buffer +} + +var _ agent.Session = (*Session)(nil) + +func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { + if out == nil { + return nil, errors.New("deepseekharness: nil out channel") + } + if cfg.logger == nil { + cfg.logger = obslog.Bg() + } + if cfg.binary == "" { + cfg.binary = defaultBinary + } + if cfg.killTimeout <= 0 { + cfg.killTimeout = 3 * time.Second + } + for _, key := range unsupportedOptions { + if value, ok := req.AgentOptions[key]; ok && value != nil { + cfg.logger.Warn("deepseekharness: agent option unsupported by dsh headless, ignored", + "run_id", req.RunID, "option", key) + } + } + + buildRes, err := BuildArgs(req.Prompt, req.WorkDir, req.AgentOptions, StateKeys{ + AgentStateKey: req.AgentStateKey, + ConversationID: req.ConversationID, + RunID: req.RunID, + }) + if err != nil { + return nil, fmt.Errorf("deepseekharness: build args: %w", err) + } + args := append([]string{}, buildRes.Args...) + args = append(args, cfg.extraArgs...) + proc, err := clirunner.Start(clirunner.StartOptions{ + Parent: parent, + Binary: cfg.binary, + Args: args, + Dir: buildRes.WorkDir, + Env: append(os.Environ(), buildRes.Env...), + KillTimeout: cfg.killTimeout, + }) + if err != nil { + buildRes.Cleanup() + return nil, fmt.Errorf("deepseekharness: start %q: %w", cfg.binary, err) + } + + s := &Session{ + runID: req.RunID, + cfg: cfg, + proc: proc, + out: out, + cancelCtx: proc.Context(), + cleanup: buildRes.Cleanup, + } + go s.pumpStderr(proc.Stderr) + go s.run(proc.Stdout) + return s, nil +} + +func (s *Session) Cancel(context.Context) error { + s.cancelOnce.Do(func() { + s.proc.Cancel() + }) + return nil +} + +func (s *Session) SubmitPermission(context.Context, string, proto.PermissionDecisionPayload) error { + return agent.ErrUnknownPermission +} + +func (s *Session) SubmitPromptForUserChoice(context.Context, string, proto.PromptForUserChoiceDecisionPayload) error { + return agent.ErrUnknownAsk +} + +func (s *Session) run(stdout io.Reader) { + defer s.cleanup() + defer s.closeOut() + + tr := newTranslator(s.runID) + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for sc.Scan() { + tr.appendLine(sc.Text()) + } + if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { + s.cfg.logger.Warn("deepseekharness: scan stdout", "run_id", s.runID, "err", err) + } + + waitErr := s.proc.Wait() + for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { + s.trySend(env) + } +} + +func (s *Session) pumpStderr(stderr io.Reader) { + sc := bufio.NewScanner(stderr) + sc.Buffer(make([]byte, 0, 16*1024), 1<<20) + for sc.Scan() { + line := sc.Text() + s.stderrMu.Lock() + if s.stderr.Len() > 0 { + s.stderr.WriteByte('\n') + } + s.stderr.WriteString(line) + s.stderrMu.Unlock() + s.cfg.logger.Warn("dsh stderr", "run_id", s.runID, "line", line) + } +} + +func (s *Session) stderrString() string { + s.stderrMu.Lock() + defer s.stderrMu.Unlock() + return s.stderr.String() +} + +func (s *Session) trySend(env proto.Envelope) { + select { + case s.out <- env: + case <-time.After(2 * time.Second): + s.cfg.logger.Warn("deepseekharness: terminal send timed out", "type", env.Type, "run_id", s.runID) + } +} + +func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go new file mode 100644 index 00000000..c05b1ecc --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/session_test.go @@ -0,0 +1,277 @@ +package deepseekharness_test + +import ( + "context" + "errors" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TestMain re-execs the test binary as a fake `dsh` when +// DSH_TESTHELPER_ROLE is set, bypassing m.Run so the framework's PASS +// line doesn't pollute fake stdout. +const dshHelperEnvKey = "DSH_TESTHELPER_ROLE" + +func TestMain(m *testing.M) { + if role := os.Getenv(dshHelperEnvKey); role != "" { + runFakeDsh(role) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func runFakeDsh(role string) { + if argsFile := os.Getenv("DSH_TESTHELPER_ARGS_FILE"); argsFile != "" { + _ = os.WriteFile(argsFile, []byte(strings.Join(os.Args, "\n")), 0o600) + } + switch role { + case "success": + _, _ = os.Stdout.WriteString("the final answer\n") + case "nonzero": + _, _ = os.Stderr.WriteString("MODEL_ERROR: upstream refused\n") + os.Exit(1) + case "hang": + time.Sleep(10 * time.Minute) + } +} + +func dshHelperConfig() deepseekharness.SessionConfigForTest { + return deepseekharness.SessionConfigForTest{ + Binary: os.Args[0], + ExtraArgs: []string{"-test.run=^$"}, + KillTimeout: 200 * time.Millisecond, + } +} + +func dshHelperReq(runID, prompt, role string) proto.PromptRequestPayload { + return proto.PromptRequestPayload{ + RunID: runID, + Prompt: prompt, + AgentStateKey: "conv1/agent1/deepseek_harness", + AgentOptions: map[string]any{ + "env": map[string]any{dshHelperEnvKey: role}, + }, + } +} + +func drainDsh(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { + t.Helper() + deadline := time.After(dl) + var got []proto.Envelope + for { + select { + case env, ok := <-out: + if !ok { + return got, true + } + got = append(got, env) + case <-deadline: + return got, false + } + } +} + +func TestSessionSuccessEmitsDeltaAndDone(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_ok", "hello", "success"), out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainDsh(t, out, 10*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := envTypes(got) + if !slices.Contains(types, proto.TypeDelta) || !slices.Contains(types, proto.TypeDone) { + t.Fatalf("types = %v, want delta+done", types) + } + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env = %q, want done; all=%v", got[len(got)-1].Type, types) + } + if slices.Contains(types, proto.TypeError) { + t.Fatalf("clean exit must not emit an error frame: %v", types) + } + done := decodePayload[proto.DonePayload](t, got[len(got)-1]) + if done.Content != "the final answer" { + t.Fatalf("done content = %q", done.Content) + } +} + +func TestSessionPassesHeadlessProfileAndPatchToCLI(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + argsFile := t.TempDir() + "/argv" + req := dshHelperReq("run_argv", "hello", "success") + req.AgentOptions["env"].(map[string]any)["DSH_TESTHELPER_ARGS_FILE"] = argsFile + + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), req, out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + if _, closed := drainDsh(t, out, 10*time.Second); !closed { + t.Fatal("out did not close") + } + + body, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read argv file: %v", err) + } + argv := strings.Split(string(body), "\n") + if !slices.Contains(argv, "--profile") || !slices.Contains(argv, "headless") { + t.Fatalf("argv missing headless profile: %v", argv) + } + if !slices.Contains(argv, "--patch") { + t.Fatalf("argv missing patch overlay: %v", argv) + } + if !slices.Contains(argv, "hello") { + t.Fatalf("argv missing task: %v", argv) + } +} + +func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_err", "hello", "nonzero"), out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + got, closed := drainDsh(t, out, 10*time.Second) + if !closed { + t.Fatalf("out did not close, drained %d envs", len(got)) + } + types := envTypes(got) + if !slices.Contains(types, proto.TypeError) { + t.Fatalf("types = %v, want error", types) + } + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env = %q, want done; all=%v", got[len(got)-1].Type, types) + } + var errPayload proto.ErrorPayload + for _, env := range got { + if env.Type == proto.TypeError { + errPayload = decodePayload[proto.ErrorPayload](t, env) + } + } + if !strings.Contains(errPayload.Error, "upstream refused") { + t.Fatalf("error payload = %#v", errPayload) + } +} + +func TestSessionCancelClosesOutAndEmitsDone(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_cancel", "hello", "hang"), out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + + time.Sleep(150 * time.Millisecond) + if err := sess.Cancel(context.Background()); err != nil { + t.Errorf("Cancel: %v", err) + } + + got, closed := drainDsh(t, out, 10*time.Second) + if !closed { + t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) + } + if got[len(got)-1].Type != proto.TypeDone { + t.Fatalf("last env = %q, want done; all=%v", got[len(got)-1].Type, envTypes(got)) + } +} + +func TestSessionCleansUpPatchFileAfterRun(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + argsFile := t.TempDir() + "/argv" + req := dshHelperReq("run_cleanup", "hello", "success") + req.AgentOptions["env"].(map[string]any)["DSH_TESTHELPER_ARGS_FILE"] = argsFile + + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), req, out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + if _, closed := drainDsh(t, out, 10*time.Second); !closed { + t.Fatal("out did not close") + } + + body, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read argv file: %v", err) + } + argv := strings.Split(string(body), "\n") + patchPath := flagValue(argv, "--patch") + if patchPath == "" { + t.Fatalf("argv missing patch path: %v", argv) + } + if _, err := os.Stat(patchPath); !os.IsNotExist(err) { + t.Fatalf("patch file must be removed once the run ends, stat err = %v", err) + } +} + +func TestSessionRejectsPermissionAndAskSubmissions(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 16) + sess, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_perm", "hello", "hang"), out, dshHelperConfig()) + if err != nil { + t.Fatalf("NewSessionForTest: %v", err) + } + defer sess.Cancel(context.Background()) + + if err := sess.SubmitPermission(context.Background(), "perm_nope", proto.PermissionDecisionPayload{Approved: true}); !errors.Is(err, agent.ErrUnknownPermission) { + t.Fatalf("SubmitPermission err = %v, want ErrUnknownPermission", err) + } + if err := sess.SubmitPromptForUserChoice(context.Background(), "ask_nope", proto.PromptForUserChoiceDecisionPayload{}); !errors.Is(err, agent.ErrUnknownAsk) { + t.Fatalf("SubmitPromptForUserChoice err = %v, want ErrUnknownAsk", err) + } +} + +func TestSessionRejectsNilOut(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + _, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_nil", "hello", "success"), nil, dshHelperConfig()) + if err == nil { + t.Fatal("expected error on nil out") + } +} + +func TestSessionRejectsEmptyPrompt(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 4) + _, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_empty", " ", "success"), out, dshHelperConfig()) + if err == nil { + t.Fatal("expected error on empty prompt") + } +} + +func TestSessionBadBinaryFailsToStart(t *testing.T) { + t.Setenv("PARSAR_HOME", t.TempDir()) + out := make(chan proto.Envelope, 4) + cfg := dshHelperConfig() + cfg.Binary = "/nonexistent/binary/that/does/not/resolve" + cfg.ExtraArgs = nil + _, err := deepseekharness.NewSessionForTest(context.Background(), + dshHelperReq("run_bad", "hello", "success"), out, cfg) + if err == nil { + t.Fatal("expected start error for bogus binary") + } +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/version.go b/apps/parsar-daemon/internal/agent/deepseekharness/version.go new file mode 100644 index 00000000..52c48af7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/version.go @@ -0,0 +1,31 @@ +package deepseekharness + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" +) + +// InstallURL points operators at the DeepSeek Harness repository when +// the daemon can see the adapter but not the CLI binary. +const InstallURL = "https://github.com/deepseek-ai/deepseek-harness" + +const defaultBinary = "dsh" + +// ErrCLINotFound is returned by CheckCLIAvailable when the binary cannot +// be located on PATH. Callers use errors.Is to distinguish an install +// problem from a present-but-broken CLI. +var ErrCLINotFound = errors.New("deepseek-harness CLI not found") + +// CheckCLIAvailable runs ` --version` and returns the trimmed +// first line. The empty binary name defaults to "dsh". +func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { + return versionprobe.Check(ctx, binary, versionprobe.Config{ + Name: "dsh", + DefaultBinary: defaultBinary, + MissingError: ErrCLINotFound, + TrimBinary: true, + StderrFallback: true, + }) +} diff --git a/apps/parsar-daemon/internal/agent/deepseekharness/version_test.go b/apps/parsar-daemon/internal/agent/deepseekharness/version_test.go new file mode 100644 index 00000000..5b0c94ad --- /dev/null +++ b/apps/parsar-daemon/internal/agent/deepseekharness/version_test.go @@ -0,0 +1,18 @@ +package deepseekharness_test + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe/testutil" +) + +func TestCheckCLIAvailableContract(t *testing.T) { + testutil.RunContract(t, testutil.Contract{ + Name: "dsh", + DefaultBinary: "dsh", + MissingError: deepseekharness.ErrCLINotFound, + Check: deepseekharness.CheckCLIAvailable, + WhitespaceDefaults: true, + }) +} diff --git a/apps/parsar-daemon/internal/agent/pi/provider_config.go b/apps/parsar-daemon/internal/agent/pi/provider_config.go index 48794e60..232d47fe 100644 --- a/apps/parsar-daemon/internal/agent/pi/provider_config.go +++ b/apps/parsar-daemon/internal/agent/pi/provider_config.go @@ -123,7 +123,7 @@ func resolveAgentDir(agentStateKey, conversationID, runID string) (string, error } base := filepath.Join(root, "runtime", "pi") if key := strings.TrimSpace(agentStateKey); key != "" { - parts := safeStatePathParts(key) + parts := paths.StateKeyParts(key) if len(parts) == 0 { return "", fmt.Errorf("pi: invalid agentStateKey %q", agentStateKey) } @@ -174,33 +174,6 @@ func applyPiRuntimeState(opts map[string]any, agentStateKey, conversationID, run return out, nil } -func safeStatePathParts(key string) []string { - rawParts := strings.Split(key, "/") - parts := make([]string, 0, len(rawParts)) - for _, part := range rawParts { - if safe := safeStatePathPart(part); safe != "" { - parts = append(parts, safe) - } - } - return parts -} - -func safeStatePathPart(part string) string { - var b strings.Builder - for _, r := range strings.TrimSpace(part) { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - out := b.String() - if out == "." || out == ".." { - return "" - } - return out -} - func withAgentDirEnv(existing any, agentDir string) map[string]any { out := map[string]any{} if m, ok := existing.(map[string]any); ok { diff --git a/apps/parsar-daemon/internal/cli/agent_cli.go b/apps/parsar-daemon/internal/cli/agent_cli.go new file mode 100644 index 00000000..d42441b8 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/agent_cli.go @@ -0,0 +1,211 @@ +// Agent CLI preflight: one table describing every engine the daemon can +// drive, its heartbeat capability descriptor, and how to probe its binary. +// Split out of connect.go so adding an engine touches one table instead of +// appending another copy of the probe/report block. +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. +type agentCLIDiscovery struct { + ClaudeCode proto.SupportedAgentKind + OpenCode proto.SupportedAgentKind + Codex proto.SupportedAgentKind + Pi proto.SupportedAgentKind + DeepseekHarness proto.SupportedAgentKind +} + +type versionCheck func(context.Context, string) (string, error) + +type agentCLIChecks struct { + ClaudeCode versionCheck + OpenCode versionCheck + Codex versionCheck + Pi versionCheck + DeepseekHarness versionCheck +} + +func defaultAgentCLIChecks() agentCLIChecks { + return agentCLIChecks{ + ClaudeCode: claudecode.CheckCLIAvailable, + OpenCode: opencodeagent.CheckCLIAvailable, + Codex: codex.CheckCLIAvailable, + Pi: pi.CheckCLIAvailable, + DeepseekHarness: deepseekharness.CheckCLIAvailable, + } +} + +// agentCLIProbe is one engine's preflight: where its descriptor lives, how +// to detect the binary, and the operator-facing strings used to report it. +type agentCLIProbe struct { + slot *proto.SupportedAgentKind + check versionCheck + fallback versionCheck + label string + versionCmd string + notFoundErr error + installURL string +} + +func preflightAgentCLIs(rc *runContext) (agentCLIDiscovery, error) { + return discoverAgentCLIs(rc, defaultAgentCLIChecks()) +} + +func discoverAgentCLIs(rc *runContext, checks agentCLIChecks) (agentCLIDiscovery, error) { + out := agentCLIDescriptors() + probes := []agentCLIProbe{ + { + slot: &out.ClaudeCode, + check: checks.ClaudeCode, + fallback: claudecode.CheckCLIAvailable, + label: "Claude Code", + versionCmd: "claude --version", + notFoundErr: claudecode.ErrCLINotFound, + installURL: claudecode.InstallURL, + }, + { + slot: &out.OpenCode, + check: checks.OpenCode, + fallback: opencodeagent.CheckCLIAvailable, + label: "OpenCode", + versionCmd: "opencode --version", + notFoundErr: opencodeagent.ErrCLINotFound, + installURL: opencodeagent.InstallURL, + }, + { + slot: &out.Codex, + check: checks.Codex, + fallback: codex.CheckCLIAvailable, + label: "Codex", + versionCmd: "codex --version", + notFoundErr: codex.ErrCLINotFound, + installURL: codex.InstallURL, + }, + { + slot: &out.Pi, + check: checks.Pi, + fallback: pi.CheckCLIAvailable, + label: "pi", + versionCmd: "pi --version", + notFoundErr: pi.ErrCLINotFound, + installURL: pi.InstallURL, + }, + { + slot: &out.DeepseekHarness, + check: checks.DeepseekHarness, + fallback: deepseekharness.CheckCLIAvailable, + label: "DeepSeek Harness", + versionCmd: "dsh --version", + notFoundErr: deepseekharness.ErrCLINotFound, + installURL: deepseekharness.InstallURL, + }, + } + + available := 0 + for _, probe := range probes { + if runAgentCLIProbe(rc, probe) { + available++ + } + } + if available == 0 { + return out, fmt.Errorf("connect: no supported agent CLI available (install Claude Code, OpenCode, Codex, pi, or DeepSeek Harness)") + } + return out, nil +} + +// runAgentCLIProbe fills the descriptor in place and reports the outcome to +// the operator. Returns whether the CLI is usable. +func runAgentCLIProbe(rc *runContext, probe agentCLIProbe) bool { + check := probe.check + if check == nil { + check = probe.fallback + } + ctx, cancel := context.WithTimeout(context.Background(), cliVersionTimeout) + version, err := check(ctx, "") + cancel() + + switch { + case err == nil: + probe.slot.Available = true + probe.slot.Version = version + fmt.Fprintf(rc.stdout, "%s preflight ok (%s)\n", probe.label, version) + return true + case errors.Is(err, probe.notFoundErr): + fmt.Fprintf(rc.stderr, "parsar-daemon: %s CLI not found on PATH; %s unavailable.\n", probe.label, probe.slot.Kind) + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", probe.installURL) + default: + fmt.Fprintf(rc.stderr, "parsar-daemon: `%s` failed; %s unavailable: %v\n", probe.versionCmd, probe.slot.Kind, err) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", probe.installURL) + } + return false +} + +// agentCLIDescriptors is the capability contract the server reads from the +// heartbeat. Availability and version are filled in by the probes. +func agentCLIDescriptors() agentCLIDiscovery { + return agentCLIDiscovery{ + ClaudeCode: proto.SupportedAgentKind{ + Kind: "claude_code", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + OpenCode: proto.SupportedAgentKind{ + Kind: "opencode", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + }, + }, + Codex: proto.SupportedAgentKind{ + Kind: "codex", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + Pi: proto.SupportedAgentKind{ + Kind: "pi", + Capabilities: proto.AgentKindCapabilities{ + // pi runs --no-approve, so no permission cards; streaming, + // usage, and --session resume are all wired. + Streaming: true, + Usage: true, + Resume: true, + }, + }, + DeepseekHarness: proto.SupportedAgentKind{ + Kind: "deepseek_harness", + // `dsh --profile headless` is the harness's only supported + // automation surface: it prints the final assistant text and + // exits, with no event stream, token accounting, resume flag, + // or approval channel to advertise. + Capabilities: proto.AgentKindCapabilities{}, + }, + } +} + +func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery) { + registry.RegisterKind(agentCLIs.ClaudeCode, claudecode.Factory) + registry.RegisterKind(agentCLIs.OpenCode, opencodeagent.Factory) + registry.RegisterKind(agentCLIs.Codex, codex.Factory) + registry.RegisterKind(agentCLIs.Pi, pi.Factory) + registry.RegisterKind(agentCLIs.DeepseekHarness, deepseekharness.Factory) +} diff --git a/apps/parsar-daemon/internal/cli/agent_cli_test.go b/apps/parsar-daemon/internal/cli/agent_cli_test.go new file mode 100644 index 00000000..c0e23945 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/agent_cli_test.go @@ -0,0 +1,269 @@ +package cli + +import ( + "context" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/deepseekharness" + opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// missingChecks stubs every engine as absent. Tests override the engines +// they care about — leaving a field nil would probe the host's real CLI and +// make the outcome depend on the developer's machine. +func missingChecks() agentCLIChecks { + return agentCLIChecks{ + ClaudeCode: func(context.Context, string) (string, error) { + return "", claudecode.ErrCLINotFound + }, + OpenCode: func(context.Context, string) (string, error) { + return "", opencodeagent.ErrCLINotFound + }, + Codex: func(context.Context, string) (string, error) { + return "", codex.ErrCLINotFound + }, + Pi: func(context.Context, string) (string, error) { + return "", pi.ErrCLINotFound + }, + DeepseekHarness: func(context.Context, string) (string, error) { + return "", deepseekharness.ErrCLINotFound + }, + } +} + +func TestDiscoverAgentCLIsAllowsOpenCodeWithoutClaude(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + checks := missingChecks() + checks.OpenCode = func(context.Context, string) (string, error) { + return "opencode 1.4.3", nil + } + got, err := discoverAgentCLIs(rc, checks) + if err != nil { + t.Fatalf("discoverAgentCLIs: %v", err) + } + if got.ClaudeCode.Available { + t.Fatalf("ClaudeCode.Available = true, want false: %#v", got.ClaudeCode) + } + if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { + t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) + } + if got.Codex.Available { + t.Fatalf("Codex.Available = true, want false: %#v", got.Codex) + } + if got.Pi.Available { + t.Fatalf("Pi.Available = true, want false: %#v", got.Pi) + } + if got.DeepseekHarness.Available { + t.Fatalf("DeepseekHarness.Available = true, want false: %#v", got.DeepseekHarness) + } + if !got.OpenCode.Capabilities.Streaming || !got.OpenCode.Capabilities.Usage || got.OpenCode.Capabilities.Permissions { + t.Fatalf("OpenCode capabilities = %#v", got.OpenCode.Capabilities) + } + if !strings.Contains(stdout.String(), "OpenCode preflight ok") { + t.Fatalf("stdout missing OpenCode ok line: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "claude_code unavailable") { + t.Fatalf("stderr missing Claude unavailable line: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "deepseek_harness unavailable") { + t.Fatalf("stderr missing DeepSeek Harness unavailable line: %q", stderr.String()) + } +} + +func TestDiscoverAgentCLIsAllowsDeepseekHarnessAlone(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + checks := missingChecks() + checks.DeepseekHarness = func(context.Context, string) (string, error) { + return "dsh 0.1.0", nil + } + got, err := discoverAgentCLIs(rc, checks) + if err != nil { + t.Fatalf("discoverAgentCLIs: %v", err) + } + if !got.DeepseekHarness.Available || got.DeepseekHarness.Version != "dsh 0.1.0" { + t.Fatalf("DeepseekHarness descriptor = %#v", got.DeepseekHarness) + } + // The headless surface streams nothing, reports no tokens, and has no + // resume flag or approval channel — advertising any of them would make + // the server wait for frames that never arrive. + if got.DeepseekHarness.Capabilities != (proto.AgentKindCapabilities{}) { + t.Fatalf("DeepseekHarness capabilities = %#v, want none", got.DeepseekHarness.Capabilities) + } + if !strings.Contains(stdout.String(), "DeepSeek Harness preflight ok") { + t.Fatalf("stdout missing DeepSeek Harness ok line: %q", stdout.String()) + } +} + +func TestDiscoverAgentCLIsAllMissingFails(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + got, err := discoverAgentCLIs(rc, missingChecks()) + if err == nil { + t.Fatalf("expected error when all CLIs missing, got descriptors %#v", got) + } + if !strings.Contains(err.Error(), "no supported agent CLI") { + t.Fatalf("unexpected error: %v", err) + } + if got.ClaudeCode.Available || got.OpenCode.Available || got.Codex.Available || got.Pi.Available || got.DeepseekHarness.Available { + t.Fatalf("available descriptors after missing CLIs: %#v", got) + } +} + +func TestDiscoverAgentCLIsAllAvailable(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + got, err := discoverAgentCLIs(rc, agentCLIChecks{ + ClaudeCode: func(context.Context, string) (string, error) { + return "claude 2.0.0", nil + }, + OpenCode: func(context.Context, string) (string, error) { + return "opencode 1.4.3", nil + }, + Codex: func(context.Context, string) (string, error) { + return "codex 0.141.0", nil + }, + Pi: func(context.Context, string) (string, error) { + return "pi 0.1.0", nil + }, + DeepseekHarness: func(context.Context, string) (string, error) { + return "dsh 0.1.0", nil + }, + }) + if err != nil { + t.Fatalf("discoverAgentCLIs: %v", err) + } + if !got.ClaudeCode.Available || got.ClaudeCode.Version != "claude 2.0.0" { + t.Fatalf("ClaudeCode descriptor = %#v", got.ClaudeCode) + } + if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { + t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) + } + if !got.Codex.Available || got.Codex.Version != "codex 0.141.0" { + t.Fatalf("Codex descriptor = %#v", got.Codex) + } + if !got.ClaudeCode.Capabilities.Permissions || !got.ClaudeCode.Capabilities.Resume { + t.Fatalf("ClaudeCode capabilities = %#v", got.ClaudeCode.Capabilities) + } + if !got.Codex.Capabilities.Streaming || !got.Codex.Capabilities.Permissions || !got.Codex.Capabilities.Resume { + t.Fatalf("Codex capabilities = %#v (want Streaming+Permissions+Resume)", got.Codex.Capabilities) + } + if !got.Pi.Available || got.Pi.Version != "pi 0.1.0" { + t.Fatalf("Pi descriptor = %#v", got.Pi) + } + if !got.Pi.Capabilities.Streaming || !got.Pi.Capabilities.Usage || !got.Pi.Capabilities.Resume || got.Pi.Capabilities.Permissions { + t.Fatalf("Pi capabilities = %#v (want Streaming+Usage+Resume, no Permissions)", got.Pi.Capabilities) + } + if !got.DeepseekHarness.Available || got.DeepseekHarness.Version != "dsh 0.1.0" { + t.Fatalf("DeepseekHarness descriptor = %#v", got.DeepseekHarness) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestDiscoverAgentCLIsReportsBrokenCLIWithVersionCommand(t *testing.T) { + stdout, stderr := &strings.Builder{}, &strings.Builder{} + rc := &runContext{stdout: stdout, stderr: stderr} + checks := missingChecks() + checks.DeepseekHarness = func(context.Context, string) (string, error) { + return "", context.DeadlineExceeded + } + if _, err := discoverAgentCLIs(rc, checks); err == nil { + t.Fatal("expected error when every CLI is unusable") + } + if !strings.Contains(stderr.String(), "`dsh --version` failed") { + t.Fatalf("stderr missing broken-CLI line: %q", stderr.String()) + } +} + +func TestRegisterAgentKindsPreservesDescriptors(t *testing.T) { + reg := agent.NewRegistry() + registerAgentKinds(reg, agentCLIDiscovery{ + ClaudeCode: proto.SupportedAgentKind{ + Kind: "claude_code", + Available: true, + Version: "claude 2.0.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + OpenCode: proto.SupportedAgentKind{ + Kind: "opencode", + Available: false, + Version: "missing", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + }, + }, + Codex: proto.SupportedAgentKind{ + Kind: "codex", + Available: true, + Version: "codex 0.141.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Permissions: true, + Usage: true, + Resume: true, + }, + }, + Pi: proto.SupportedAgentKind{ + Kind: "pi", + Available: true, + Version: "pi 0.1.0", + Capabilities: proto.AgentKindCapabilities{ + Streaming: true, + Usage: true, + Resume: true, + }, + }, + DeepseekHarness: proto.SupportedAgentKind{ + Kind: "deepseek_harness", + Available: true, + Version: "dsh 0.1.0", + }, + }) + + kinds := reg.SupportedAgentKinds() + if len(kinds) != 5 { + t.Fatalf("SupportedAgentKinds len = %d, want 5: %#v", len(kinds), kinds) + } + // Sorted: claude_code, codex, deepseek_harness, opencode, pi. + want := []string{"claude_code", "codex", "deepseek_harness", "opencode", "pi"} + for i, kind := range want { + if kinds[i].Kind != kind { + t.Fatalf("SupportedAgentKinds sort = %#v", kinds) + } + } + if !kinds[0].Available || kinds[0].Version != "claude 2.0.0" || !kinds[0].Capabilities.Permissions { + t.Fatalf("claude descriptor not preserved: %#v", kinds[0]) + } + if !kinds[1].Available || kinds[1].Version != "codex 0.141.0" || !kinds[1].Capabilities.Resume { + t.Fatalf("codex descriptor not preserved: %#v", kinds[1]) + } + if !kinds[2].Available || kinds[2].Version != "dsh 0.1.0" || kinds[2].Capabilities.Resume { + t.Fatalf("deepseek_harness descriptor not preserved: %#v", kinds[2]) + } + if kinds[3].Available || kinds[3].Version != "missing" || !kinds[3].Capabilities.Streaming || !kinds[3].Capabilities.Usage { + t.Fatalf("opencode descriptor not preserved: %#v", kinds[3]) + } + if !kinds[4].Available || kinds[4].Version != "pi 0.1.0" || !kinds[4].Capabilities.Resume || kinds[4].Capabilities.Permissions { + t.Fatalf("pi descriptor not preserved: %#v", kinds[4]) + } + for _, kind := range want { + if _, err := reg.Resolve(kind); err != nil { + t.Fatalf("%s factory not registered: %v", kind, err) + } + } +} diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 54578400..679f48e7 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -12,10 +12,6 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" - opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" @@ -189,158 +185,6 @@ func resolveConnectProfile(profile, serverURL, token, deviceName string) (auth.P return prof, nil } -// agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. -type agentCLIDiscovery struct { - ClaudeCode proto.SupportedAgentKind - OpenCode proto.SupportedAgentKind - Codex proto.SupportedAgentKind - Pi proto.SupportedAgentKind -} - -type agentCLIChecks struct { - ClaudeCode func(context.Context, string) (string, error) - OpenCode func(context.Context, string) (string, error) - Codex func(context.Context, string) (string, error) - Pi func(context.Context, string) (string, error) -} - -func defaultAgentCLIChecks() agentCLIChecks { - return agentCLIChecks{ - ClaudeCode: claudecode.CheckCLIAvailable, - OpenCode: opencodeagent.CheckCLIAvailable, - Codex: codex.CheckCLIAvailable, - Pi: pi.CheckCLIAvailable, - } -} - -func preflightAgentCLIs(rc *runContext) (agentCLIDiscovery, error) { - return discoverAgentCLIs(rc, defaultAgentCLIChecks()) -} - -func discoverAgentCLIs(rc *runContext, checks agentCLIChecks) (agentCLIDiscovery, error) { - if checks.ClaudeCode == nil { - checks.ClaudeCode = claudecode.CheckCLIAvailable - } - if checks.OpenCode == nil { - checks.OpenCode = opencodeagent.CheckCLIAvailable - } - if checks.Codex == nil { - checks.Codex = codex.CheckCLIAvailable - } - if checks.Pi == nil { - checks.Pi = pi.CheckCLIAvailable - } - out := agentCLIDiscovery{ - ClaudeCode: proto.SupportedAgentKind{ - Kind: "claude_code", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }, - OpenCode: proto.SupportedAgentKind{ - Kind: "opencode", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Usage: true, - }, - }, - Codex: proto.SupportedAgentKind{ - Kind: "codex", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }, - Pi: proto.SupportedAgentKind{ - Kind: "pi", - Capabilities: proto.AgentKindCapabilities{ - // pi runs --no-approve, so no permission cards; streaming, - // usage, and --session resume are all wired. - Streaming: true, - Usage: true, - Resume: true, - }, - }, - } - - claudeCtx, cancelClaude := context.WithTimeout(context.Background(), cliVersionTimeout) - claudeVersion, claudeErr := checks.ClaudeCode(claudeCtx, "") - cancelClaude() - if claudeErr == nil { - out.ClaudeCode.Available = true - out.ClaudeCode.Version = claudeVersion - fmt.Fprintf(rc.stdout, "Claude Code preflight ok (%s)\n", claudeVersion) - } else if errors.Is(claudeErr, claudecode.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "parsar-daemon: Claude Code CLI not found on PATH; claude_code unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", claudecode.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "parsar-daemon: `claude --version` failed; claude_code unavailable: %v\n", claudeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", claudecode.InstallURL) - } - - opencodeCtx, cancelOpenCode := context.WithTimeout(context.Background(), cliVersionTimeout) - opencodeVersion, opencodeErr := checks.OpenCode(opencodeCtx, "") - cancelOpenCode() - if opencodeErr == nil { - out.OpenCode.Available = true - out.OpenCode.Version = opencodeVersion - fmt.Fprintf(rc.stdout, "OpenCode preflight ok (%s)\n", opencodeVersion) - } else if errors.Is(opencodeErr, opencodeagent.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "parsar-daemon: OpenCode CLI not found on PATH; opencode unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", opencodeagent.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "parsar-daemon: `opencode --version` failed; opencode unavailable: %v\n", opencodeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", opencodeagent.InstallURL) - } - - codexCtx, cancelCodex := context.WithTimeout(context.Background(), cliVersionTimeout) - codexVersion, codexErr := checks.Codex(codexCtx, "") - cancelCodex() - if codexErr == nil { - out.Codex.Available = true - out.Codex.Version = codexVersion - fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) - } else if errors.Is(codexErr, codex.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "parsar-daemon: Codex CLI not found on PATH; codex unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", codex.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "parsar-daemon: `codex --version` failed; codex unavailable: %v\n", codexErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", codex.InstallURL) - } - - piCtx, cancelPi := context.WithTimeout(context.Background(), cliVersionTimeout) - piVersion, piErr := checks.Pi(piCtx, "") - cancelPi() - if piErr == nil { - out.Pi.Available = true - out.Pi.Version = piVersion - fmt.Fprintf(rc.stdout, "pi preflight ok (%s)\n", piVersion) - } else if errors.Is(piErr, pi.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "parsar-daemon: pi CLI not found on PATH; pi unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", pi.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "parsar-daemon: `pi --version` failed; pi unavailable: %v\n", piErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", pi.InstallURL) - } - - if !out.ClaudeCode.Available && !out.OpenCode.Available && !out.Codex.Available && !out.Pi.Available { - return out, fmt.Errorf("connect: no supported agent CLI available (install Claude Code, OpenCode, Codex, or pi)") - } - return out, nil -} - -func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery) { - registry.RegisterKind(agentCLIs.ClaudeCode, claudecode.Factory) - registry.RegisterKind(agentCLIs.OpenCode, opencodeagent.Factory) - registry.RegisterKind(agentCLIs.Codex, codex.Factory) - registry.RegisterKind(agentCLIs.Pi, pi.Factory) -} - // spawnBackground forks the daemon into the background. Parent // returns after printing the child PID; child re-enters runConnect // with BackgroundSentinelEnv set so the same mainLoop runs in either diff --git a/apps/parsar-daemon/internal/cli/connect_test.go b/apps/parsar-daemon/internal/cli/connect_test.go index a2f226eb..218478be 100644 --- a/apps/parsar-daemon/internal/cli/connect_test.go +++ b/apps/parsar-daemon/internal/cli/connect_test.go @@ -1,18 +1,10 @@ package cli import ( - "context" "os" "reflect" "strings" "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudecode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" - opencodeagent "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/opencode" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/pi" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) func TestScrubInlineConnectArgsRemovesTokenURLAndDeviceName(t *testing.T) { @@ -66,197 +58,3 @@ func TestLoadInlineConnectEnvHydratesParentProcessFlags(t *testing.T) { t.Fatalf("inlinePair=false after env hydration; serverURL=%q token=%q", serverURL, token) } } - -func TestDiscoverAgentCLIsAllowsOpenCodeWithoutClaude(t *testing.T) { - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, agentCLIChecks{ - ClaudeCode: func(context.Context, string) (string, error) { - return "", claudecode.ErrCLINotFound - }, - OpenCode: func(context.Context, string) (string, error) { - return "opencode 1.4.3", nil - }, - Codex: func(context.Context, string) (string, error) { - return "", codex.ErrCLINotFound - }, - Pi: func(context.Context, string) (string, error) { - return "", pi.ErrCLINotFound - }, - }) - if err != nil { - t.Fatalf("discoverAgentCLIs: %v", err) - } - if got.ClaudeCode.Available { - t.Fatalf("ClaudeCode.Available = true, want false: %#v", got.ClaudeCode) - } - if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { - t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) - } - if got.Codex.Available { - t.Fatalf("Codex.Available = true, want false: %#v", got.Codex) - } - if got.Pi.Available { - t.Fatalf("Pi.Available = true, want false: %#v", got.Pi) - } - if !got.OpenCode.Capabilities.Streaming || !got.OpenCode.Capabilities.Usage || got.OpenCode.Capabilities.Permissions { - t.Fatalf("OpenCode capabilities = %#v", got.OpenCode.Capabilities) - } - if !strings.Contains(stdout.String(), "OpenCode preflight ok") { - t.Fatalf("stdout missing OpenCode ok line: %q", stdout.String()) - } - if !strings.Contains(stderr.String(), "claude_code unavailable") { - t.Fatalf("stderr missing Claude unavailable line: %q", stderr.String()) - } -} - -func TestDiscoverAgentCLIsBothMissingFails(t *testing.T) { - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, agentCLIChecks{ - ClaudeCode: func(context.Context, string) (string, error) { - return "", claudecode.ErrCLINotFound - }, - OpenCode: func(context.Context, string) (string, error) { - return "", opencodeagent.ErrCLINotFound - }, - Codex: func(context.Context, string) (string, error) { - return "", codex.ErrCLINotFound - }, - Pi: func(context.Context, string) (string, error) { - return "", pi.ErrCLINotFound - }, - }) - if err == nil { - t.Fatalf("expected error when all CLIs missing, got descriptors %#v", got) - } - if !strings.Contains(err.Error(), "no supported agent CLI") { - t.Fatalf("unexpected error: %v", err) - } - if got.ClaudeCode.Available || got.OpenCode.Available || got.Codex.Available || got.Pi.Available { - t.Fatalf("available descriptors after missing CLIs: %#v", got) - } -} - -func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, agentCLIChecks{ - ClaudeCode: func(context.Context, string) (string, error) { - return "claude 2.0.0", nil - }, - OpenCode: func(context.Context, string) (string, error) { - return "opencode 1.4.3", nil - }, - Codex: func(context.Context, string) (string, error) { - return "codex 0.141.0", nil - }, - Pi: func(context.Context, string) (string, error) { - return "pi 0.1.0", nil - }, - }) - if err != nil { - t.Fatalf("discoverAgentCLIs: %v", err) - } - if !got.ClaudeCode.Available || got.ClaudeCode.Version != "claude 2.0.0" { - t.Fatalf("ClaudeCode descriptor = %#v", got.ClaudeCode) - } - if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { - t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) - } - if !got.Codex.Available || got.Codex.Version != "codex 0.141.0" { - t.Fatalf("Codex descriptor = %#v", got.Codex) - } - if !got.ClaudeCode.Capabilities.Permissions || !got.ClaudeCode.Capabilities.Resume { - t.Fatalf("ClaudeCode capabilities = %#v", got.ClaudeCode.Capabilities) - } - if !got.Codex.Capabilities.Streaming || !got.Codex.Capabilities.Permissions || !got.Codex.Capabilities.Resume { - t.Fatalf("Codex capabilities = %#v (want Streaming+Permissions+Resume)", got.Codex.Capabilities) - } - if !got.Pi.Available || got.Pi.Version != "pi 0.1.0" { - t.Fatalf("Pi descriptor = %#v", got.Pi) - } - if !got.Pi.Capabilities.Streaming || !got.Pi.Capabilities.Usage || !got.Pi.Capabilities.Resume || got.Pi.Capabilities.Permissions { - t.Fatalf("Pi capabilities = %#v (want Streaming+Usage+Resume, no Permissions)", got.Pi.Capabilities) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } -} - -func TestRegisterAgentKindsPreservesDescriptors(t *testing.T) { - reg := agent.NewRegistry() - registerAgentKinds(reg, agentCLIDiscovery{ - ClaudeCode: proto.SupportedAgentKind{ - Kind: "claude_code", - Available: true, - Version: "claude 2.0.0", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }, - OpenCode: proto.SupportedAgentKind{ - Kind: "opencode", - Available: false, - Version: "missing", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Usage: true, - }, - }, - Codex: proto.SupportedAgentKind{ - Kind: "codex", - Available: true, - Version: "codex 0.141.0", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }, - Pi: proto.SupportedAgentKind{ - Kind: "pi", - Available: true, - Version: "pi 0.1.0", - Capabilities: proto.AgentKindCapabilities{ - Streaming: true, - Usage: true, - Resume: true, - }, - }, - }) - - kinds := reg.SupportedAgentKinds() - if len(kinds) != 4 { - t.Fatalf("SupportedAgentKinds len = %d, want 4: %#v", len(kinds), kinds) - } - // Sorted: claude_code, codex, opencode, pi. - if kinds[0].Kind != "claude_code" || kinds[1].Kind != "codex" || kinds[2].Kind != "opencode" || kinds[3].Kind != "pi" { - t.Fatalf("SupportedAgentKinds sort = %#v", kinds) - } - if !kinds[0].Available || kinds[0].Version != "claude 2.0.0" || !kinds[0].Capabilities.Permissions { - t.Fatalf("claude descriptor not preserved: %#v", kinds[0]) - } - if !kinds[1].Available || kinds[1].Version != "codex 0.141.0" || !kinds[1].Capabilities.Resume { - t.Fatalf("codex descriptor not preserved: %#v", kinds[1]) - } - if kinds[2].Available || kinds[2].Version != "missing" || !kinds[2].Capabilities.Streaming || !kinds[2].Capabilities.Usage { - t.Fatalf("opencode descriptor not preserved: %#v", kinds[2]) - } - if !kinds[3].Available || kinds[3].Version != "pi 0.1.0" || !kinds[3].Capabilities.Resume || kinds[3].Capabilities.Permissions { - t.Fatalf("pi descriptor not preserved: %#v", kinds[3]) - } - if _, err := reg.Resolve("opencode"); err != nil { - t.Fatalf("opencode factory not registered: %v", err) - } - if _, err := reg.Resolve("codex"); err != nil { - t.Fatalf("codex factory not registered: %v", err) - } - if _, err := reg.Resolve("pi"); err != nil { - t.Fatalf("pi factory not registered: %v", err) - } -} diff --git a/apps/parsar-daemon/internal/paths/statekey.go b/apps/parsar-daemon/internal/paths/statekey.go new file mode 100644 index 00000000..21ebc896 --- /dev/null +++ b/apps/parsar-daemon/internal/paths/statekey.go @@ -0,0 +1,37 @@ +package paths + +import "strings" + +// StateKeyParts splits an AgentStateKey into filesystem-safe path parts for +// an adapter state directory under Root(). It is the traversal guard for a +// server-supplied key: every part is reduced to [A-Za-z0-9._-], and "." / +// ".." parts are dropped rather than sanitized into a name that still +// escapes. An empty result means the key carried nothing usable. +func StateKeyParts(key string) []string { + rawParts := strings.Split(key, "/") + parts := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + if safe := SafePathPart(part); safe != "" { + parts = append(parts, safe) + } + } + return parts +} + +// SafePathPart reduces one path component to filesystem-safe characters, +// returning "" for a component that cannot be used as a directory name. +func SafePathPart(part string) string { + var b strings.Builder + for _, r := range strings.TrimSpace(part) { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + out := b.String() + if out == "." || out == ".." { + return "" + } + return out +} diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 1b3892ea..403deb82 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1783,6 +1783,11 @@ "title": "Pi", "description": "Run the pi coding agent CLI through parsar-daemon." }, + "deepseekHarness": { + "title": "DeepSeek Harness", + "cardHint": "One-shot runs: each prompt starts a fresh session with no memory of earlier turns.", + "description": "Run DeepSeek Harness (dsh) one-shot headless tasks through parsar-daemon. Each prompt starts a fresh harness session and returns its final answer." + }, "opencode": { "title": "OpenCode", "description": "Run OpenCode CLI through parsar-daemon for local or sandbox daemon workflows." diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index c6e813c3..59d4470a 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1783,6 +1783,11 @@ "title": "Pi", "description": "通过 parsar-daemon 运行 pi coding agent CLI。" }, + "deepseekHarness": { + "title": "DeepSeek Harness", + "cardHint": "单次执行:每次提问都会新建会话,不保留之前轮次的上下文。", + "description": "通过 parsar-daemon 以 dsh 单次无界面任务方式运行 DeepSeek Harness。每次提问都会新建一个 harness 会话并返回最终回答。" + }, "opencode": { "title": "OpenCode", "description": "通过 parsar-daemon 运行 OpenCode CLI,适合本机或 sandbox daemon 的 OpenCode 工作流。" diff --git a/apps/web/src/lib/agent-view-model.ts b/apps/web/src/lib/agent-view-model.ts index d4e396d5..80aadc6f 100644 --- a/apps/web/src/lib/agent-view-model.ts +++ b/apps/web/src/lib/agent-view-model.ts @@ -1,6 +1,6 @@ import type { Agent, AgentDetail, CapabilityType, Model } from "./api-types" -export type AgentEngine = "claude_code" | "codex" | "pi" | "opencode" +export type AgentEngine = "claude_code" | "codex" | "pi" | "opencode" | "deepseek_harness" export type CodexCollaborationMode = "default" | "plan" @@ -11,6 +11,7 @@ export type AgentEngineLabelKey = | "agents.engine.codex.title" | "agents.engine.pi.title" | "agents.engine.opencode.title" + | "agents.engine.deepseekHarness.title" type AgentSource = Agent | AgentDetail | null | undefined type UnknownRecord = Record @@ -51,6 +52,9 @@ function normalizeEngine(value: string): AgentEngine | null { case "opencode": case "open_code": return "opencode" + case "deepseek_harness": + case "dsh": + return "deepseek_harness" default: return null } @@ -83,6 +87,8 @@ export function agentEngineLabel(engine: AgentEngine): AgentEngineLabelKey { return "agents.engine.pi.title" case "opencode": return "agents.engine.opencode.title" + case "deepseek_harness": + return "agents.engine.deepseekHarness.title" } } @@ -95,11 +101,13 @@ export function agentEngineSupportsCapability(engine: AgentEngine, capabilityTyp return capabilityType === "mcp" || capabilityType === "system_prompt" case "pi": return capabilityType === "skill" || capabilityType === "system_prompt" + case "deepseek_harness": + return capabilityType === "system_prompt" } } export function agentEnginesSupportingCapability(capabilityType: CapabilityType): AgentEngine[] { - return (["claude_code", "codex", "pi", "opencode"] as const).filter((engine) => + return (["claude_code", "codex", "pi", "opencode", "deepseek_harness"] as const).filter((engine) => agentEngineSupportsCapability(engine, capabilityType), ) } @@ -174,6 +182,26 @@ export function defaultModelOf(agent: AgentSource, models: Model[], unavailableL return found.name || found.model_key || id } +/** Display name for a raw agent_kind reported by a runtime heartbeat. + * Runtimes may advertise kinds this build does not model yet, so an + * unrecognized value falls back to the wire string. */ +export function agentKindDisplayName(kind: string): string { + switch (normalizeEngine(kind)) { + case "claude_code": + return "Claude Code" + case "opencode": + return "OpenCode" + case "codex": + return "Codex" + case "pi": + return "PI Agent" + case "deepseek_harness": + return "DeepSeek Harness" + default: + return kind + } +} + export function agentConnectorLabel(connectorType: string): string { if (connectorType === "agent_daemon") return "Agent Daemon" if (connectorType === "http-agent" || connectorType === "http") return "HTTP Agent" diff --git a/apps/web/src/pages/admin/CreateAgentDialog.tsx b/apps/web/src/pages/admin/CreateAgentDialog.tsx index 55bbc727..4dc8423c 100644 --- a/apps/web/src/pages/admin/CreateAgentDialog.tsx +++ b/apps/web/src/pages/admin/CreateAgentDialog.tsx @@ -1,7 +1,7 @@ import { Fragment, forwardRef, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from "react" import { useTranslation } from "react-i18next" import { useQueryClient } from "@tanstack/react-query" -import { ArrowUpRight, Bot, Check, ChevronDown, Cloud, Cpu, Eye, EyeOff, Laptop, Network, Search, Server, Sparkles } from "lucide-react" +import { ArrowUpRight, Bot, Check, ChevronDown, Cloud, Cpu, Eye, EyeOff, Laptop, Network, Search, Server, Sparkles, Waves } from "lucide-react" import { Badge } from "../../components/ui/badge" import { Button } from "../../components/ui/button" @@ -17,7 +17,7 @@ import { import { Input } from "../../components/ui/input" import { Tabs, TabsList, TabsTrigger } from "../../components/ui/tabs" import { ApiError } from "../../lib/api-client" -import { agentCodexModeOf, type CodexCollaborationMode } from "../../lib/agent-view-model" +import { agentCodexModeOf, type AgentEngine, type CodexCollaborationMode } from "../../lib/agent-view-model" import { modelProtocols, modelSupportedEndpointTypes, @@ -46,7 +46,6 @@ import type { const DEFAULT_WORK_DIR = "/workspace" type ExecutionMode = "sandbox" | "local_device" | "external" -type AgentEngine = "claude_code" | "opencode" | "codex" | "pi" type SandboxSize = "standard" | "xl" type RuntimeChoice = AgentRuntime type WizardStep = 1 | 2 @@ -70,6 +69,7 @@ function agentEngineFromAgent(a?: Agent | null): AgentEngine { if (v === "opencode") return "opencode" if (v === "codex") return "codex" if (v === "pi") return "pi" + if (v === "deepseek_harness") return "deepseek_harness" return "claude_code" } @@ -83,6 +83,7 @@ function engineSupportsProtocol(engine: AgentEngine, protocol: WireProtocol | nu case "codex": return protocol === "openai" case "pi": + case "deepseek_harness": return protocol === "anthropic" || protocol === "openai" || protocol === "google" case "opencode": return true @@ -98,6 +99,7 @@ function engineSupportsModel(engine: AgentEngine, model: Model): boolean { case "codex": return endpointTypes.includes("openai") || endpointTypes.includes("openai-response") case "pi": + case "deepseek_harness": return ( endpointTypes.includes("anthropic") || endpointTypes.includes("openai") || @@ -686,7 +688,12 @@ export function CreateAgentDialog({ const hasConnector = true const connector = mode === "edit" && agent ? agent.connector_type : connectorForExecutionMode(executionMode) const hasModel = activeModels.length > 0 - const requiresModel = connector !== "agent_daemon" || agentEngine === "claude_code" || agentEngine === "codex" || agentEngine === "pi" + const requiresModel = + connector !== "agent_daemon" || + agentEngine === "claude_code" || + agentEngine === "codex" || + agentEngine === "pi" || + agentEngine === "deepseek_harness" const selectedModelUnavailable = mode === "edit" && requiresModel && selectedModelID !== "" && selectedModel === null const hasRequiredModel = !requiresModel || (selectedModel !== null && !incompatibleModelIDs.has(selectedModel.id)) // Create opens model binding on a pending "shared" pick because secrets may @@ -1093,6 +1100,13 @@ export function CreateAgentDialog({ selected={agentEngine === "pi"} onSelect={() => setAgentEngine("pi")} /> + } + title={t("agents.engine.deepseekHarness.title")} + description={t("agents.engine.deepseekHarness.cardHint")} + selected={agentEngine === "deepseek_harness"} + onSelect={() => setAgentEngine("deepseek_harness")} + /> } title={t("agents.engine.opencode.title")} diff --git a/apps/web/src/pages/admin/RuntimePage.tsx b/apps/web/src/pages/admin/RuntimePage.tsx index f9004ba7..1b7eb118 100644 --- a/apps/web/src/pages/admin/RuntimePage.tsx +++ b/apps/web/src/pages/admin/RuntimePage.tsx @@ -30,6 +30,7 @@ import { TableRow, } from "../../components/ui/table" import { useAdminView } from "../../lib/admin-router" +import { agentKindDisplayName } from "../../lib/agent-view-model" import { ApiError } from "../../lib/api-client" import { useRuntimeStatus, @@ -837,24 +838,10 @@ function runtimeConfigText(runtime: Runtime, key: string): string { function formatRuntimeAgentKinds(runtime: Runtime): string { const labels = supportedAgentKinds(runtime) .filter((kind) => kind.available) - .map((kind) => formatAgentKindLabel(kind.kind)) + .map((kind) => agentKindDisplayName(kind.kind)) return labels.length > 0 ? labels.join(" · ") : "—" } -function formatAgentKindLabel(kind: string): string { - switch (kind) { - case "claude_code": - return "Claude Code" - case "opencode": - return "OpenCode" - case "codex": - return "Codex" - case "pi": - return "PI Agent" - default: - return kind - } -} function shortID(id: string): string { return id.length > 12 ? id.slice(0, 12) : id diff --git a/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx b/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx index e99cf558..96b3dfc1 100644 --- a/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx +++ b/apps/web/src/pages/admin/runtimes/LocalDeviceRuntimesPanel.tsx @@ -32,6 +32,7 @@ import { type Runtime, type SupportedAgentKind, } from "../../../lib/api-runtimes" +import { agentKindDisplayName } from "../../../lib/agent-view-model" import { useWorkspaceId } from "../../../lib/workspace" export function LocalDeviceRuntimesPanel() { @@ -229,7 +230,7 @@ function AgentKindBadges({ runtime }: { runtime: Runtime }) { className={kind.available ? "" : "opacity-70"} title={formatAgentKindTitle(kind, t)} > - {formatAgentKindLabel(kind.kind)} + {agentKindDisplayName(kind.kind)} ))} @@ -243,24 +244,10 @@ function AgentKindBadges({ runtime }: { runtime: Runtime }) { ) } -function formatAgentKindLabel(kind: string): string { - switch (kind) { - case "claude_code": - return "Claude Code" - case "opencode": - return "OpenCode" - case "codex": - return "Codex" - case "pi": - return "PI Agent" - default: - return kind - } -} function formatAgentKindTitle(kind: SupportedAgentKind, t: TFunction<"admin">): string { const parts = [ - formatAgentKindLabel(kind.kind), + agentKindDisplayName(kind.kind), kind.available ? t("runtime.agentDaemon.agentKind.available", { defaultValue: "available" }) : t("runtime.agentDaemon.agentKind.unavailable", { defaultValue: "unavailable" }), @@ -270,7 +257,7 @@ function formatAgentKindTitle(kind: SupportedAgentKind, t: TFunction<"admin">): } function formatAgentKindSnapshot(kind: SupportedAgentKind, t: TFunction<"admin">): string { - const label = formatAgentKindLabel(kind.kind) + const label = agentKindDisplayName(kind.kind) if (!kind.available) { return t("runtime.agentDaemon.agentKind.notDetected", { label, defaultValue: "{{label}} not detected" }) } diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 181d5230..34627702 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -198,6 +198,13 @@ definitions: prompt. The connector decides how to fold it into its own prompt schema. type: string + triggerMessageID: + description: |- + TriggerMessageID is the persisted message id behind + TriggerMessageContent. Empty for callers that synthesize a prompt + without a stored message. Connectors use it to tell the current + task apart from the conversation's stored transcript. + type: string workspaceID: type: string type: object diff --git a/docs/spec-memory-module.md b/docs/spec-memory-module.md index 650405e1..96675f8f 100644 --- a/docs/spec-memory-module.md +++ b/docs/spec-memory-module.md @@ -331,7 +331,7 @@ PARSAR_RUNTIME_ID= PARSAR_WORKSPACE_ID= PARSAR_USER_ID= PARSAR_PROJECT_ID= # may be empty -PARSAR_CONNECTOR=claude|opencode|codex +PARSAR_CONNECTOR=claude|opencode|codex|pi|deepseek-harness PARSAR_PROJECT_AGENT_ID= PARSAR_CONVERSATION_ID= ``` diff --git a/infra/sandbox/Dockerfile b/infra/sandbox/Dockerfile index a34dded1..2da59681 100644 --- a/infra/sandbox/Dockerfile +++ b/infra/sandbox/Dockerfile @@ -24,8 +24,8 @@ # one does). # # Contents: -# - Claude Code CLI + Codex CLI + Pi CLI (installed by -# infra/sandbox/scripts/install-agents.sh) +# - Claude Code CLI + Codex CLI + Pi CLI + DeepSeek Harness CLI +# (installed by infra/sandbox/scripts/install-agents.sh) # - parsar-daemon + parsar CLI (spec/memory hook injection), both # compiled from source in the builder stage below — no dependency on # a published parsar-daemon release or the parsar:local server image @@ -39,9 +39,11 @@ # Build args: # --build-arg BASE_IMAGE= ubuntu:22.04 (local) or e2bdev/base:latest (e2b) # --build-arg GO_VERSION= builder Go image (default: matches repo go.work) +# --build-arg GO_IMAGE= full builder reference (default: golang:$GO_VERSION) # --build-arg CLAUDE_CODE_VERSION= pin claude code (default: latest) # --build-arg CODEX_VERSION= pin codex-rs (default: 0.141.0) # --build-arg PI_VERSION= pin pi CLI (default: 0.80.6) +# --build-arg DSH_VERSION= pin DeepSeek Harness CLI (default: 0.1.0-rc.7) ############################################################################### # Stage 1: go-builder — compile parsar-daemon + parsar CLI for TARGETARCH. @@ -50,9 +52,18 @@ ############################################################################### ARG GO_VERSION=1.25-bookworm ARG BASE_IMAGE=ubuntu:22.04 +# Full builder reference so a host that cannot reach docker.io can point it +# at a mirror (--build-arg GO_IMAGE=/library/golang:1.25-bookworm), +# mirroring what BASE_IMAGE already allows for the runtime stage. +ARG GO_IMAGE=golang:${GO_VERSION} -FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS go-builder +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-builder ARG TARGETARCH +# Overridable module proxy: the default matches Go's own, but a build host +# that cannot reach proxy.golang.org can point at a mirror +# (--build-arg GOPROXY=https://goproxy.cn,direct). +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=${GOPROXY} WORKDIR /src # Module graph first, source second — keeps `go mod download` cacheable. @@ -97,14 +108,16 @@ RUN apt-get update -y \ curl ca-certificates jq git ripgrep \ && rm -rf /var/lib/apt/lists/* -# --- Agent CLIs (Node + Claude Code + Codex + Pi) --- +# --- Agent CLIs (Node + Claude Code + Codex + Pi + DeepSeek Harness) --- # See infra/sandbox/scripts/install-agents.sh for the install logic and # version-pin build args. ARG CLAUDE_CODE_VERSION="" ARG CODEX_VERSION="0.141.0" ARG PI_VERSION="0.80.6" +ARG DSH_VERSION="0.1.0-rc.7" COPY infra/sandbox/scripts/install-agents.sh /tmp/install-agents.sh RUN CLAUDE_CODE_VERSION="$CLAUDE_CODE_VERSION" CODEX_VERSION="$CODEX_VERSION" PI_VERSION="$PI_VERSION" \ + DSH_VERSION="$DSH_VERSION" \ /tmp/install-agents.sh "$TARGETARCH" \ && rm -f /tmp/install-agents.sh diff --git a/infra/sandbox/scripts/install-agents.sh b/infra/sandbox/scripts/install-agents.sh index 39bae008..b9e9f95e 100755 --- a/infra/sandbox/scripts/install-agents.sh +++ b/infra/sandbox/scripts/install-agents.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Installs every agent CLI a Parsar sandbox image needs: Node 22 (for npm- -# based installs), Claude Code, Codex, and Pi. Used by +# based installs), Claude Code, Codex, Pi, and DeepSeek Harness. Used by # infra/sandbox/Dockerfile (both the local-docker and e2b.app build # targets, selected by --build-arg BASE_IMAGE). Edit here, not inline in # the Dockerfile. @@ -10,6 +10,7 @@ # CLAUDE_CODE_VERSION default: latest # CODEX_VERSION default: 0.141.0 # PI_VERSION default: 0.80.6 +# DSH_VERSION default: 0.1.0-rc.7 # # All installs are FAIL-LOUD: `set -e` + a `--version` sanity check after # each one. A silently missing CLI would only surface at run time when a @@ -21,6 +22,7 @@ TARGETARCH="${1:?install-agents.sh: TARGETARCH required (amd64|arm64)}" CLAUDE_CODE_VERSION="${CLAUDE_CODE_VERSION:-}" CODEX_VERSION="${CODEX_VERSION:-0.141.0}" PI_VERSION="${PI_VERSION:-0.80.6}" +DSH_VERSION="${DSH_VERSION:-0.1.0-rc.7}" case "$TARGETARCH" in amd64) CLAUDE_ARCH=linux-x64 CODEX_ARCH=x86_64-unknown-linux-musl ;; @@ -87,3 +89,11 @@ rm -rf /tmp/codex* echo "install-agents: installing pi ${PI_VERSION}" npm install -g "@earendil-works/pi-coding-agent@${PI_VERSION}" pi --version + +# --- DeepSeek Harness CLI (via npm) --- +# Developer preview with documented compatibility-breaking changes between +# releases, so this pin is load-bearing: `latest` would silently change the +# headless grammar the daemon adapter drives. +echo "install-agents: installing dsh ${DSH_VERSION}" +npm install -g "@deepseek-ai/dsh@${DSH_VERSION}" +dsh --version diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index f518823a..35c8dc02 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -438,19 +438,22 @@ func main() { agentDaemonSandbox := buildAgentDaemonSandboxProvider(envLookup, cfg, dbStore, agentDaemonRegistry, agentDaemonBinder, agentDaemonPodID) agentDaemonRemote := connagentdaemon.HTTPRemoteStreamer{Token: agentDaemonInternalToken} agentDaemonCfg := connagentdaemon.Config{ - Registry: agentDaemonRegistry, - Binder: agentDaemonBinder, - Sandbox: agentDaemonSandbox, - OwnerResolver: dbStore, - OwnerPodID: agentDaemonPodID, - Remote: agentDaemonRemote, - RemoteSubmit: agentDaemonRemote, - SubmitSlots: dbStore, - ModelResolver: dbStore, - ExecutionRecorder: dbStore, - RunStatusReader: dbStore, - Capabilities: dbStore, - MasterKey: cfg.Secret.MasterKey, + Registry: agentDaemonRegistry, + Binder: agentDaemonBinder, + Sandbox: agentDaemonSandbox, + OwnerResolver: dbStore, + OwnerPodID: agentDaemonPodID, + Remote: agentDaemonRemote, + RemoteSubmit: agentDaemonRemote, + SubmitSlots: dbStore, + ModelResolver: dbStore, + // Transcript injection for engines that cannot resume their + // own session (opencode, deepseek_harness). + ConversationHistory: dbStore, + ExecutionRecorder: dbStore, + RunStatusReader: dbStore, + Capabilities: dbStore, + MasterKey: cfg.Secret.MasterKey, // Auto-mounted fetch_chat_history tool: the endpoint URL the // sandbox calls back into, plus the per-conversation token signer. // Nil signer (empty master key) disables the injection. diff --git a/server/internal/capability/render/deepseekharness.go b/server/internal/capability/render/deepseekharness.go new file mode 100644 index 00000000..ca8a6551 --- /dev/null +++ b/server/internal/capability/render/deepseekharness.go @@ -0,0 +1,37 @@ +package render + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical" +) + +// deepseekHarnessRenderer serializes capability specs for the DeepSeek +// Harness runtime (`dsh --profile headless`). That surface takes a task +// string and a config overlay only: the daemon adapter folds a rendered +// system prompt into the task text, while skills, managed MCP servers and +// plugins have no seam there and return ErrUnsupported, which the +// agentdaemon connector treats as a soft degrade (skip + disabled-capability +// notice). +type deepseekHarnessRenderer struct{} + +func (deepseekHarnessRenderer) Target() Target { return TargetDeepseekHarness } + +func (deepseekHarnessRenderer) Supports(kind canonical.Kind) bool { + return kind == canonical.KindSystemPrompt +} + +func (deepseekHarnessRenderer) Render(_ context.Context, spec canonical.Spec) (Output, error) { + if err := spec.Validate(); err != nil { + return Output{}, fmt.Errorf("deepseek_harness render: invalid spec: %w", err) + } + switch spec.Kind { + case canonical.KindSystemPrompt: + return renderSystemPrompt(spec.SystemPrompt) + case canonical.KindSkill, canonical.KindMCP, canonical.KindPlugin: + return Output{}, ErrUnsupported + default: + return Output{}, fmt.Errorf("deepseek_harness render: unknown kind %q", spec.Kind) + } +} diff --git a/server/internal/capability/render/renderer.go b/server/internal/capability/render/renderer.go index ec4c8094..37164bfd 100644 --- a/server/internal/capability/render/renderer.go +++ b/server/internal/capability/render/renderer.go @@ -26,10 +26,11 @@ import ( type Target string const ( - TargetOpenCode Target = "opencode" - TargetClaudeCode Target = "claudecode" - TargetCodex Target = "codex" - TargetPi Target = "pi" + TargetOpenCode Target = "opencode" + TargetClaudeCode Target = "claudecode" + TargetCodex Target = "codex" + TargetPi Target = "pi" + TargetDeepseekHarness Target = "deepseekharness" ) // Output is what a Renderer returns. Content is the scaffold-specific JSON @@ -62,6 +63,8 @@ func TargetForAgentKind(agentKind string) Target { return TargetCodex case "pi": return TargetPi + case "deepseek_harness": + return TargetDeepseekHarness default: return TargetClaudeCode } @@ -83,6 +86,8 @@ func For(target Target) (Renderer, error) { return codexRenderer{}, nil case TargetPi: return piRenderer{}, nil + case TargetDeepseekHarness: + return deepseekHarnessRenderer{}, nil default: return nil, fmt.Errorf("render: unknown target %q", target) } diff --git a/server/internal/capability/render/renderer_test.go b/server/internal/capability/render/renderer_test.go index 87f617a1..c34a63f0 100644 --- a/server/internal/capability/render/renderer_test.go +++ b/server/internal/capability/render/renderer_test.go @@ -59,7 +59,7 @@ func remoteMCPFixture() canonical.Spec { // TestFor_KnownTargets catches "added a Target without wiring For()". func TestFor_KnownTargets(t *testing.T) { - for _, target := range []Target{TargetOpenCode, TargetClaudeCode, TargetCodex, TargetPi} { + for _, target := range []Target{TargetOpenCode, TargetClaudeCode, TargetCodex, TargetPi, TargetDeepseekHarness} { r, err := For(target) if err != nil { t.Fatalf("For(%q) error: %v", target, err) @@ -90,6 +90,9 @@ func TestSupports(t *testing.T) { {TargetOpenCode, canonical.KindSkill, false}, {TargetPi, canonical.KindMCP, false}, {TargetPi, canonical.KindSkill, true}, + {TargetDeepseekHarness, canonical.KindMCP, false}, + {TargetDeepseekHarness, canonical.KindSkill, false}, + {TargetDeepseekHarness, canonical.KindSystemPrompt, true}, } for _, tc := range cases { t.Run(string(tc.target)+"/"+string(tc.kind), func(t *testing.T) { diff --git a/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go b/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go index 3e233c9f..ad543a08 100644 --- a/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go +++ b/server/internal/connector/agentdaemon/capability_runtime_dispatch_test.go @@ -25,6 +25,7 @@ func TestTargetForAgentKind(t *testing.T) { {"opencode", render.TargetOpenCode}, {"codex", render.TargetCodex}, {"pi", render.TargetPi}, + {"deepseek_harness", render.TargetDeepseekHarness}, {"", render.TargetClaudeCode}, {" claude_code ", render.TargetClaudeCode}, {"unknown_engine", render.TargetClaudeCode}, diff --git a/server/internal/connector/agentdaemon/connector.go b/server/internal/connector/agentdaemon/connector.go index 14af6f9e..83672ed9 100644 --- a/server/internal/connector/agentdaemon/connector.go +++ b/server/internal/connector/agentdaemon/connector.go @@ -111,6 +111,11 @@ type Config struct { // the master key). Nil disables the tool injection. IMHistoryTokenSigner func(conversationID string) string + // ConversationHistory enables the server-side transcript injection for + // engines that advertise Capabilities.Resume=false (opencode, + // deepseek_harness). Nil leaves those engines stateless across turns. + ConversationHistory ConversationHistoryReader + // ExecutionRecorder persists the per-run execution snapshot. Nil // keeps tests on the pre-snapshot behavior. ExecutionRecorder ExecutionSnapshotRecorder @@ -159,26 +164,27 @@ type Config struct { // "agent_daemon". One instance lives for the lifetime of the server // process; concurrency is delegated to gateway.Registry + binding.Binder. type Connector struct { - registry *gateway.Registry - binder binding.Binder - sandbox SandboxProvider - ownerResolver DeviceOwnerResolver - ownerPodID string - remote RemoteStreamer - remoteSubmit RemoteSubmitter - submitSlots SubmitSlotResolver - modelResolver ModelResolver - executionRecorder ExecutionSnapshotRecorder - runStatus AgentRunStatusReader - secrets *secrets.Service - capabilities CapabilityRuntimeStore - specMemory SpecMemoryInjector - oss OSSPresigner - systemMessages CapabilitySystemMessageStore - sandboxBindings SandboxBindingReader - imHistoryEndpoint string - imHistoryToken func(conversationID string) string - log *slog.Logger + registry *gateway.Registry + binder binding.Binder + sandbox SandboxProvider + ownerResolver DeviceOwnerResolver + ownerPodID string + remote RemoteStreamer + remoteSubmit RemoteSubmitter + submitSlots SubmitSlotResolver + modelResolver ModelResolver + conversationHistory ConversationHistoryReader + executionRecorder ExecutionSnapshotRecorder + runStatus AgentRunStatusReader + secrets *secrets.Service + capabilities CapabilityRuntimeStore + specMemory SpecMemoryInjector + oss OSSPresigner + systemMessages CapabilitySystemMessageStore + sandboxBindings SandboxBindingReader + imHistoryEndpoint string + imHistoryToken func(conversationID string) string + log *slog.Logger } // ExecutionSnapshotRecorder is satisfied by *store.Store. @@ -242,26 +248,27 @@ func New(cfg Config) *Connector { } } return &Connector{ - registry: cfg.Registry, - binder: cfg.Binder, - sandbox: cfg.Sandbox, - ownerResolver: cfg.OwnerResolver, - ownerPodID: cfg.OwnerPodID, - remote: cfg.Remote, - remoteSubmit: cfg.RemoteSubmit, - submitSlots: cfg.SubmitSlots, - modelResolver: cfg.ModelResolver, - executionRecorder: cfg.ExecutionRecorder, - runStatus: cfg.RunStatusReader, - secrets: cfg.Secrets, - capabilities: cfg.Capabilities, - specMemory: cfg.SpecMemory, - oss: cfg.OSS, - systemMessages: cfg.SystemMessages, - sandboxBindings: cfg.SandboxBindingReader, - imHistoryEndpoint: cfg.IMHistoryEndpoint, - imHistoryToken: cfg.IMHistoryTokenSigner, - log: cfg.Log, + registry: cfg.Registry, + binder: cfg.Binder, + sandbox: cfg.Sandbox, + ownerResolver: cfg.OwnerResolver, + ownerPodID: cfg.OwnerPodID, + remote: cfg.Remote, + remoteSubmit: cfg.RemoteSubmit, + submitSlots: cfg.SubmitSlots, + modelResolver: cfg.ModelResolver, + conversationHistory: cfg.ConversationHistory, + executionRecorder: cfg.ExecutionRecorder, + runStatus: cfg.RunStatusReader, + secrets: cfg.Secrets, + capabilities: cfg.Capabilities, + specMemory: cfg.SpecMemory, + oss: cfg.OSS, + systemMessages: cfg.SystemMessages, + sandboxBindings: cfg.SandboxBindingReader, + imHistoryEndpoint: cfg.IMHistoryEndpoint, + imHistoryToken: cfg.IMHistoryTokenSigner, + log: cfg.Log, } } @@ -454,6 +461,10 @@ func (c *Connector) streamPrompt(ctx context.Context, in connector.PromptInput, } kindInfo, _, _ := sess.AgentKindStatus(agentKind) c.recordExecutionSnapshot(ctx, in, bind, agentKind, kindInfo) + // Runs here rather than in buildAgentOptions: the resume capability is + // a property of the device that will execute the run, and the heartbeat + // descriptor only exists once its session is resolved. + c.applyConversationHistoryInjection(ctx, agentOptions, in, kindInfo) upstream, err := sess.Subscribe(in.RunID) if err != nil { diff --git a/server/internal/connector/agentdaemon/history_injection.go b/server/internal/connector/agentdaemon/history_injection.go new file mode 100644 index 00000000..4cd210e2 --- /dev/null +++ b/server/internal/connector/agentdaemon/history_injection.go @@ -0,0 +1,182 @@ +// Server-side conversation history for engines that cannot resume. +// +// claude_code, codex and pi keep their own conversation state and get an +// upstream session id back through agent_engine_sessions, so the daemon +// replays nothing for them. opencode and deepseek_harness advertise +// Capabilities.Resume=false: every prompt is a fresh engine session, so +// without this injection turn two has no idea what turn one said. +// +// The transcript is folded into the system-prompt slot, which every adapter +// already forwards (as --append-system-prompt, or prepended to the task for +// the engines with no system-prompt flag). It is deliberately a bounded tail +// rather than the whole conversation: these engines have no prompt-cache +// reuse, so every injected byte is paid for on every turn. +package agentdaemon + +import ( + "context" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +const ( + // historyTurnLimit bounds how many stored turns are read and rendered. + historyTurnLimit = 12 + + // historyTotalBudgetBytes caps the rendered block. Oldest turns are + // dropped first once the budget is exhausted. + historyTotalBudgetBytes = 6000 + + // historyMessageBudgetBytes caps one turn so a single pasted log cannot + // consume the whole block. + historyMessageBudgetBytes = 800 +) + +const historyHeader = `## Earlier turns in this conversation + +You start every turn without memory of previous ones, so the recent +exchange is reproduced below for context. It is history, not a new +request: do not answer it again, and do not repeat work already done.` + +// ConversationHistoryReader is the narrow read surface the injection needs. +// Satisfied by *store.Store. +type ConversationHistoryReader interface { + ListRecentConversationHistory(ctx context.Context, conversationID string, limit int32) ([]store.ConversationHistoryMessage, error) +} + +// applyConversationHistoryInjection appends the recent transcript to +// opts["system_prompt"] when the bound engine cannot resume its own session. +// +// The gate is the device's live heartbeat descriptor rather than a +// server-side list of engine names, so an engine that gains resume support +// stops getting a duplicate transcript the moment it advertises it. +// +// Fail-soft: a read error is logged and swallowed. Losing context degrades +// an answer; failing the prompt loses the turn. +func (c *Connector) applyConversationHistoryInjection( + ctx context.Context, + opts map[string]any, + in connector.PromptInput, + info store.AgentDaemonSupportedAgentKind, +) { + if c.conversationHistory == nil || opts == nil { + return + } + if info.Capabilities.Resume { + return + } + // An explicit override owns the whole system prompt, mirroring + // applySpecMemoryInjection and applyIMHistoryPromptInjection. + if stringFromMap(opts, "override_system_prompt") != "" { + return + } + if strings.TrimSpace(in.ConversationID) == "" { + return + } + + messages, err := c.conversationHistory.ListRecentConversationHistory(ctx, in.ConversationID, historyTurnLimit) + if err != nil { + c.log.Warn("agent_daemon: conversation history read failed; proceeding without transcript", + "run_id", in.RunID, "conversation_id", in.ConversationID, "err", err.Error()) + return + } + block := renderConversationHistory(messages, in.TriggerMessageID, in.TriggerMessageContent) + if block == "" { + return + } + base := stringFromMap(opts, "system_prompt") + if base == "" { + opts["system_prompt"] = block + } else { + opts["system_prompt"] = base + "\n\n" + block + } + c.log.Info("agent_daemon: conversation history injected", + "run_id", in.RunID, + "agent_kind", info.Kind, + "turn_count", len(messages), + "block_bytes", len(block)) +} + +// renderConversationHistory renders stored turns oldest-first, excluding the +// message that triggered this run. Returns "" when nothing is left to say. +func renderConversationHistory(messages []store.ConversationHistoryMessage, triggerMessageID, triggerContent string) string { + lines := make([]string, 0, len(messages)) + for _, msg := range messages { + if isTriggerMessage(msg, triggerMessageID, triggerContent) { + continue + } + content := strings.TrimSpace(msg.Content) + if content == "" { + continue + } + lines = append(lines, historySpeaker(msg.SenderType)+": "+truncateHistoryText(content, historyMessageBudgetBytes)) + } + if len(lines) == 0 { + return "" + } + // Drop from the oldest end until the block fits; the newest turns are + // the ones the next answer depends on. + budget := historyTotalBudgetBytes - len(historyHeader) + for len(lines) > 1 && historyBlockSize(lines) > budget { + lines = lines[1:] + } + if historyBlockSize(lines) > budget { + lines[0] = truncateHistoryText(lines[0], budget) + } + return historyHeader + "\n\n" + strings.Join(lines, "\n\n") +} + +func historyBlockSize(lines []string) int { + total := 0 + for _, line := range lines { + total += len(line) + 2 + } + return total +} + +// isTriggerMessage reports whether a stored turn is the task this run is +// already carrying. The id is authoritative; the content comparison only +// covers callers that synthesize a prompt without a stored message id, and +// tolerates the gateway's quoted-chain prefix, which rides on the dispatched +// content but not on the stored row. +func isTriggerMessage(msg store.ConversationHistoryMessage, triggerMessageID, triggerContent string) bool { + if id := strings.TrimSpace(triggerMessageID); id != "" { + return msg.ID == id + } + stored := strings.TrimSpace(msg.Content) + trigger := strings.TrimSpace(triggerContent) + if stored == "" || trigger == "" { + return false + } + return stored == trigger || strings.HasSuffix(trigger, stored) +} + +func historySpeaker(senderType string) string { + switch strings.TrimSpace(senderType) { + case "agent": + return "Assistant" + default: + // user + external (unregistered IM sender) are both humans here. + return "User" + } +} + +func truncateHistoryText(text string, budget int) string { + if budget <= 0 || len(text) <= budget { + return text + } + const marker = "… [truncated]" + if budget <= len(marker) { + return text[:budget] + } + cut := budget - len(marker) + // Trim a partial UTF-8 sequence rather than emitting a broken rune. + for cut > 0 && !utf8Boundary(text[cut]) { + cut-- + } + return text[:cut] + marker +} + +func utf8Boundary(b byte) bool { return b&0xC0 != 0x80 } diff --git a/server/internal/connector/agentdaemon/history_injection_test.go b/server/internal/connector/agentdaemon/history_injection_test.go new file mode 100644 index 00000000..4370f528 --- /dev/null +++ b/server/internal/connector/agentdaemon/history_injection_test.go @@ -0,0 +1,248 @@ +package agentdaemon + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +type fakeHistoryReader struct { + messages []store.ConversationHistoryMessage + err error + calls int + gotLimit int32 + gotConv string +} + +func (f *fakeHistoryReader) ListRecentConversationHistory(_ context.Context, conversationID string, limit int32) ([]store.ConversationHistoryMessage, error) { + f.calls++ + f.gotConv = conversationID + f.gotLimit = limit + return f.messages, f.err +} + +func historyMessages() []store.ConversationHistoryMessage { + base := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + return []store.ConversationHistoryMessage{ + {ID: "m1", SenderType: "user", Content: "add a health endpoint", CreatedAt: base}, + {ID: "m2", SenderType: "agent", Content: "added /healthz in api.go", CreatedAt: base.Add(time.Minute)}, + {ID: "m3", SenderType: "user", Content: "now add a readiness probe", CreatedAt: base.Add(2 * time.Minute)}, + } +} + +func historyInput() connector.PromptInput { + return connector.PromptInput{ + RunID: "run-1", + ConversationID: "conv-1", + AgentID: "agt-1", + TriggerMessageID: "m3", + TriggerMessageContent: "now add a readiness probe", + } +} + +func noResumeKind() store.AgentDaemonSupportedAgentKind { + return store.AgentDaemonSupportedAgentKind{ + Kind: "deepseek_harness", + Available: true, + } +} + +func resumeKind() store.AgentDaemonSupportedAgentKind { + info := store.AgentDaemonSupportedAgentKind{Kind: "claude_code", Available: true} + info.Capabilities.Resume = true + return info +} + +// The transcript exists only for engines that start every turn from zero. +// An engine that resumes its own session would receive the same history +// twice — once from its session, once from us. +func TestApplyConversationHistoryInjection_ResumeCapableEngineSkipped(t *testing.T) { + reader := &fakeHistoryReader{messages: historyMessages()} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), resumeKind()) + + if reader.calls != 0 { + t.Fatalf("resume-capable engine must not even read history; calls=%d", reader.calls) + } + if _, ok := opts["system_prompt"]; ok { + t.Fatalf("system_prompt must stay absent: %#v", opts) + } +} + +func TestApplyConversationHistoryInjection_NoResumeEngineGetsTranscript(t *testing.T) { + reader := &fakeHistoryReader{messages: historyMessages()} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{"system_prompt": "be terse"} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + + if reader.calls != 1 || reader.gotConv != "conv-1" { + t.Fatalf("reader calls=%d conv=%q", reader.calls, reader.gotConv) + } + if reader.gotLimit != historyTurnLimit { + t.Fatalf("limit = %d, want %d", reader.gotLimit, historyTurnLimit) + } + prompt, _ := opts["system_prompt"].(string) + if !strings.HasPrefix(prompt, "be terse\n\n") { + t.Fatalf("existing system prompt must be preserved first: %q", prompt) + } + if !strings.Contains(prompt, "User: add a health endpoint") { + t.Fatalf("missing user turn: %q", prompt) + } + if !strings.Contains(prompt, "Assistant: added /healthz in api.go") { + t.Fatalf("missing assistant turn: %q", prompt) + } + // The current task is already the prompt; echoing it as history would + // invite the engine to answer it twice. + if strings.Contains(prompt, "now add a readiness probe") { + t.Fatalf("trigger message must be excluded: %q", prompt) + } +} + +func TestApplyConversationHistoryInjection_OverrideSystemPromptWins(t *testing.T) { + reader := &fakeHistoryReader{messages: historyMessages()} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{"override_system_prompt": "you are root"} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + + if reader.calls != 0 { + t.Fatalf("override must short-circuit before the read; calls=%d", reader.calls) + } + if _, ok := opts["system_prompt"]; ok { + t.Fatalf("override must not gain a system_prompt: %#v", opts) + } +} + +// A history read failure costs context; failing the prompt costs the turn. +func TestApplyConversationHistoryInjection_ReadErrorIsSwallowed(t *testing.T) { + reader := &fakeHistoryReader{err: errors.New("db down")} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{"system_prompt": "base"} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + + if got := opts["system_prompt"]; got != "base" { + t.Fatalf("system_prompt = %v, want untouched", got) + } +} + +func TestApplyConversationHistoryInjection_FirstTurnAddsNothing(t *testing.T) { + // Only the trigger message exists yet. + reader := &fakeHistoryReader{messages: []store.ConversationHistoryMessage{ + {ID: "m3", SenderType: "user", Content: "now add a readiness probe"}, + }} + c := &Connector{conversationHistory: reader, log: discardLogger()} + opts := map[string]any{} + + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + + if _, ok := opts["system_prompt"]; ok { + t.Fatalf("a first turn has no history to inject: %#v", opts) + } +} + +func TestApplyConversationHistoryInjection_NilReaderIsNoOp(t *testing.T) { + c := &Connector{log: discardLogger()} + opts := map[string]any{} + c.applyConversationHistoryInjection(context.Background(), opts, historyInput(), noResumeKind()) + if len(opts) != 0 { + t.Fatalf("unwired reader must change nothing: %#v", opts) + } +} + +func TestRenderConversationHistory_OldestFirstAndBounded(t *testing.T) { + long := strings.Repeat("x", historyMessageBudgetBytes*2) + messages := []store.ConversationHistoryMessage{ + {ID: "m1", SenderType: "user", Content: "first"}, + {ID: "m2", SenderType: "external", Content: "im guest asks"}, + {ID: "m3", SenderType: "agent", Content: long}, + {ID: "m4", SenderType: "user", Content: " "}, + } + block := renderConversationHistory(messages, "", "unrelated trigger") + + firstIdx := strings.Index(block, "first") + guestIdx := strings.Index(block, "im guest asks") + if firstIdx == -1 || guestIdx == -1 || firstIdx > guestIdx { + t.Fatalf("turns must render oldest-first: %q", block) + } + // An unregistered IM sender is still a human on the other side. + if !strings.Contains(block, "User: im guest asks") { + t.Fatalf("external sender must render as User: %q", block) + } + if !strings.Contains(block, "… [truncated]") { + t.Fatalf("oversized turn must be truncated: %q", block[:200]) + } + if strings.Contains(block, "User: ") { + t.Fatalf("blank turns must be dropped: %q", block) + } + if len(block) > historyTotalBudgetBytes { + t.Fatalf("block is %d bytes, over the %d budget", len(block), historyTotalBudgetBytes) + } +} + +func TestRenderConversationHistory_DropsOldestUntilItFits(t *testing.T) { + chunk := strings.Repeat("y", historyMessageBudgetBytes) + messages := make([]store.ConversationHistoryMessage, 0, historyTurnLimit) + for i := 0; i < historyTurnLimit; i++ { + messages = append(messages, store.ConversationHistoryMessage{ + ID: string(rune('a' + i)), + SenderType: "user", + Content: chunk, + }) + } + // Mark the newest turn so we can prove it survived the trim. + messages[len(messages)-1].Content = "NEWEST " + chunk + + block := renderConversationHistory(messages, "", "") + if len(block) > historyTotalBudgetBytes { + t.Fatalf("block is %d bytes, over the %d budget", len(block), historyTotalBudgetBytes) + } + if !strings.Contains(block, "NEWEST") { + t.Fatalf("the newest turn must survive the trim: %q", block[:200]) + } +} + +// Without a stored message id (synthesized prompts), the content fallback +// still has to recognise the task — including the gateway's quoted-chain +// prefix, which rides on the dispatched content but not on the stored row. +func TestRenderConversationHistory_TriggerFallbackHandlesQuotedPrefix(t *testing.T) { + messages := []store.ConversationHistoryMessage{ + {ID: "m1", SenderType: "agent", Content: "earlier answer"}, + {ID: "m2", SenderType: "user", Content: "please retry"}, + } + block := renderConversationHistory(messages, "", "[Quoted message] ...\n\nplease retry") + if strings.Contains(block, "please retry") { + t.Fatalf("quoted-prefixed trigger must still be excluded: %q", block) + } + if !strings.Contains(block, "earlier answer") { + t.Fatalf("older turns must remain: %q", block) + } +} + +func TestRenderConversationHistory_EmptyInputRendersNothing(t *testing.T) { + if got := renderConversationHistory(nil, "", ""); got != "" { + t.Fatalf("expected empty render, got %q", got) + } +} + +func TestTruncateHistoryTextKeepsValidUTF8(t *testing.T) { + text := strings.Repeat("汉", 40) + got := truncateHistoryText(text, 30) + if len(got) > 30 { + t.Fatalf("truncated text is %d bytes, over budget", len(got)) + } + if !strings.HasSuffix(got, "… [truncated]") { + t.Fatalf("expected truncation marker, got %q", got) + } + if strings.ContainsRune(got, '\uFFFD') { + t.Fatalf("truncation broke a rune: %q", got) + } +} diff --git a/server/internal/connector/agentdaemon/model_injection.go b/server/internal/connector/agentdaemon/model_injection.go index bb86dd11..e0b0b84c 100644 --- a/server/internal/connector/agentdaemon/model_injection.go +++ b/server/internal/connector/agentdaemon/model_injection.go @@ -286,6 +286,18 @@ func (c *Connector) injectManagedModel(ctx context.Context, in connector.PromptI "provider_slug", mr.ProviderType, "pi_model", stringFromMap(opts, "model")) return nil + case "deepseek_harness": + if err := injectDeepseekHarnessManagedModel(opts, modelID, mr, apiKey); err != nil { + return err + } + c.log.Info("agent_daemon: injectManagedModel ok", + "run_id", in.RunID, + "agent_kind", agentKind, + "model_id", modelID, + "model_key", mr.ModelKey, + "provider_slug", mr.ProviderType, + "dsh_model", stringFromMap(opts, "model")) + return nil default: return fmt.Errorf("%w: %q", ErrUnsupportedAgentKind, agentKind) } diff --git a/server/internal/connector/agentdaemon/model_injection_deepseek.go b/server/internal/connector/agentdaemon/model_injection_deepseek.go new file mode 100644 index 00000000..4c34d2af --- /dev/null +++ b/server/internal/connector/agentdaemon/model_injection_deepseek.go @@ -0,0 +1,77 @@ +package agentdaemon + +import ( + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +// deepseekHarnessAPIKeyEnv is the env var the daemon sets to the decrypted +// secret and that the materialised dsh patch overlay references through the +// route's apiKeyEnv field. Carrying the key by env-var name keeps it off the +// dsh child's argv, where `ps` would leak it. +const deepseekHarnessAPIKeyEnv = "PARSAR_DSH_API_KEY" + +// injectDeepseekHarnessManagedModel stamps the Parsar-managed model into an +// agent_kind="deepseek_harness" prompt_request. +// +// DeepSeek Harness resolves its model through the `agent-default-model` row, +// which must name a live llm route. Its shipped DeepSeek route hard-codes the +// upstream endpoint, so a Parsar gateway model has to arrive as a declared +// route on the harness's generic `llm-pi-ai` adapter instead. That adapter is +// backed by the same @earendil-works/pi-ai library the pi CLI uses, so the +// wire-protocol mapping is shared with injectPiManagedModel. +// +// What lands in agent_options: +// +// dsh_provider: +// base_url — mr.BaseURL (required; a declared route has no default) +// api — pi-ai wire protocol (anthropic-messages / +// openai-completions / google-generative-ai) +// api_key_env — deepseekHarnessAPIKeyEnv, referenced by the route +// model — mr.ModelKey, the route's single catalog entry +// name — mr.ModelName (display, optional) +// headers — flattened mr.ProviderConfig.headers (e.g. X-Sub-Module) +// model — mr.ModelKey, pinned on agent-default-model +// env[deepseekHarnessAPIKeyEnv] — the decrypted secret +// +// All guards run before any opts mutation so a rejection leaves opts clean. +func injectDeepseekHarnessManagedModel(opts map[string]any, modelID string, mr store.ModelRuntime, apiKey string) error { + api := piAPIProtocol(mr) + if api == "" { + return fmt.Errorf("%w: model_id=%s provider_type=%q adapter=%q", + ErrManagedModelUnsupported, modelID, mr.ProviderType, mr.Adapter) + } + modelKey := strings.TrimSpace(mr.ModelKey) + if modelKey == "" { + return fmt.Errorf("%w: model_id=%s deepseek_harness requires a model_key", + ErrManagedModelConfigInvalid, modelID) + } + baseURL := modelEndpointBaseURL(mr, piAPIEndpointType(mr)) + if baseURL == "" { + return fmt.Errorf("%w: model_id=%s base_url is required for deepseek_harness provider injection", + ErrManagedModelConfigInvalid, modelID) + } + + provider := map[string]any{ + "base_url": baseURL, + "api": api, + "api_key_env": deepseekHarnessAPIKeyEnv, + "model": modelKey, + } + if name := strings.TrimSpace(mr.ModelName); name != "" { + provider["name"] = name + } + if headers := flattenStringMap(mr.ProviderConfig, "headers"); len(headers) > 0 { + provider["headers"] = headers + } + + opts["model"] = modelKey + opts["dsh_provider"] = provider + + env := copyStringAnyMap(opts["env"]) + env[deepseekHarnessAPIKeyEnv] = apiKey + opts["env"] = env + return nil +} diff --git a/server/internal/connector/agentdaemon/model_injection_deepseek_test.go b/server/internal/connector/agentdaemon/model_injection_deepseek_test.go new file mode 100644 index 00000000..fa25e4e1 --- /dev/null +++ b/server/internal/connector/agentdaemon/model_injection_deepseek_test.go @@ -0,0 +1,213 @@ +package agentdaemon + +import ( + "context" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/agentdaemon/binding" + "github.com/MiniMax-AI-Dev/parsar/server/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/server/internal/secrets" + "github.com/MiniMax-AI-Dev/parsar/server/internal/store" +) + +// TestInjectDeepseekHarnessManagedModel_HappyPath pins the contract the dsh +// adapter materialises into its `--patch` overlay: a declared llm-pi-ai route +// carrying base_url + wire protocol + headers, the model key pinned for +// agent-default-model, and the secret delivered only through opts["env"] +// under the name the route references. +func TestInjectDeepseekHarnessManagedModel_HappyPath(t *testing.T) { + opts := map[string]any{ + "env": map[string]any{"OTHER_FLAG": "kept"}, + } + mr := store.ModelRuntime{ + ModelID: "model-ds", + ModelKey: "deepseek-v4", + ModelName: "DeepSeek V4", + ProviderType: "openai-compatible", + Adapter: "@ai-sdk/openai-compatible", + BaseURL: "https://gateway.example.com/v1", + ProviderConfig: map[string]any{ + "headers": map[string]any{"X-Sub-Module": "parsar"}, + }, + } + if err := injectDeepseekHarnessManagedModel(opts, mr.ModelID, mr, "sk-dsh"); err != nil { + t.Fatalf("injectDeepseekHarnessManagedModel: %v", err) + } + if got := opts["model"]; got != "deepseek-v4" { + t.Fatalf("opts[model] = %v, want deepseek-v4", got) + } + provider, ok := opts["dsh_provider"].(map[string]any) + if !ok { + t.Fatalf("opts[dsh_provider] has type %T, want map[string]any", opts["dsh_provider"]) + } + if got := provider["base_url"]; got != "https://gateway.example.com/v1" { + t.Fatalf("dsh_provider.base_url = %v, want the platform base_url", got) + } + if got := provider["api"]; got != "openai-completions" { + t.Fatalf("dsh_provider.api = %v, want openai-completions", got) + } + if got := provider["api_key_env"]; got != "PARSAR_DSH_API_KEY" { + t.Fatalf("dsh_provider.api_key_env = %v, want PARSAR_DSH_API_KEY", got) + } + if got := provider["model"]; got != "deepseek-v4" { + t.Fatalf("dsh_provider.model = %v, want deepseek-v4", got) + } + if got := provider["name"]; got != "DeepSeek V4" { + t.Fatalf("dsh_provider.name = %v, want DeepSeek V4", got) + } + headers, ok := provider["headers"].(map[string]string) + if !ok { + t.Fatalf("dsh_provider.headers has type %T, want map[string]string", provider["headers"]) + } + if got := headers["X-Sub-Module"]; got != "parsar" { + t.Fatalf("dsh_provider.headers[X-Sub-Module] = %q", got) + } + // The key rides the environment only: the overlay file the daemon + // writes references it by name, so it never lands on dsh's argv. + if _, ok := provider["api_key"]; ok { + t.Fatalf("dsh_provider must not carry the raw key: %+v", provider) + } + env, ok := opts["env"].(map[string]any) + if !ok { + t.Fatalf("opts[env] has type %T", opts["env"]) + } + if got := env["PARSAR_DSH_API_KEY"]; got != "sk-dsh" { + t.Fatalf("env[PARSAR_DSH_API_KEY] = %v, want sk-dsh", got) + } + if got := env["OTHER_FLAG"]; got != "kept" { + t.Fatalf("existing env must survive the merge: %+v", env) + } +} + +// TestInjectDeepseekHarnessManagedModel_ProviderMapping pins the +// provider_type / endpoint-type → pi-ai wire protocol mapping the harness's +// llm-pi-ai adapter shares with the pi CLI. +func TestInjectDeepseekHarnessManagedModel_ProviderMapping(t *testing.T) { + cases := []struct { + name string + mr store.ModelRuntime + wantAPI string + }{ + { + name: "anthropic", + mr: store.ModelRuntime{ModelKey: "claude-opus-4-7", ProviderType: "anthropic", BaseURL: "https://x.example/anthropic"}, + wantAPI: "anthropic-messages", + }, + { + name: "openai", + mr: store.ModelRuntime{ModelKey: "gpt-4o", ProviderType: "openai", BaseURL: "https://x.example/v1"}, + wantAPI: "openai-completions", + }, + { + name: "google", + mr: store.ModelRuntime{ModelKey: "gemini-2.5-pro", ProviderType: "google", BaseURL: "https://x.example"}, + wantAPI: "google-generative-ai", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := map[string]any{} + if err := injectDeepseekHarnessManagedModel(opts, "model-x", tc.mr, "sk-x"); err != nil { + t.Fatalf("inject: %v", err) + } + provider, ok := opts["dsh_provider"].(map[string]any) + if !ok { + t.Fatalf("opts[dsh_provider] has type %T", opts["dsh_provider"]) + } + if got := provider["api"]; got != tc.wantAPI { + t.Fatalf("dsh_provider.api = %v, want %v", got, tc.wantAPI) + } + }) + } +} + +// A route the harness has to declare itself needs base_url, api and a model +// id, so each missing piece fails at the server boundary and leaves opts +// clean rather than shipping an overlay dsh refuses at boot. +func TestInjectDeepseekHarnessManagedModel_RejectsIncompleteRuntime(t *testing.T) { + cases := []struct { + name string + mr store.ModelRuntime + wantErr error + }{ + { + name: "unmapped provider", + mr: store.ModelRuntime{ModelKey: "cmd-r", ProviderType: "cohere", BaseURL: "https://x.example"}, + wantErr: ErrManagedModelUnsupported, + }, + { + name: "missing model key", + mr: store.ModelRuntime{ProviderType: "openai", BaseURL: "https://x.example/v1"}, + wantErr: ErrManagedModelConfigInvalid, + }, + { + name: "missing base url", + mr: store.ModelRuntime{ModelKey: "gpt-4o", ProviderType: "openai"}, + wantErr: ErrManagedModelConfigInvalid, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := map[string]any{} + err := injectDeepseekHarnessManagedModel(opts, "model-x", tc.mr, "sk-x") + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + if len(opts) != 0 { + t.Fatalf("rejection must leave opts clean: %+v", opts) + } + }) + } +} + +// TestInjectManagedModel_DeepseekHarnessSwitchWired drives the full +// c.injectManagedModel path so a missing `case "deepseek_harness"` (which +// would fall through to ErrUnsupportedAgentKind and break every run) is +// caught here. +func TestInjectManagedModel_DeepseekHarnessSwitchWired(t *testing.T) { + svc, err := secrets.New("test-master-key") + if err != nil { + t.Fatal(err) + } + enc, err := svc.Encrypt(map[string]any{"api_key": "sk-dsh-platform"}) + if err != nil { + t.Fatal(err) + } + resolver := fakeModelResolver{ + runtime: store.ModelRuntime{ + ModelID: "model-ds", + ModelKey: "deepseek-v4", + ProviderType: "openai-compatible", + Adapter: "@ai-sdk/openai-compatible", + BaseURL: "https://gateway.example.com/v1", + SecretID: "secret-ds", + }, + secret: store.SecretPayload{SecretRead: store.SecretRead{Status: "active"}, EncryptedPayload: enc}, + } + c := New(Config{ + Registry: gateway.NewRegistry(), + Binder: binding.NewInMemoryBinder(), + ModelResolver: &resolver, + Secrets: svc, + }) + + in := basicInput() + in.WorkspaceID = "ws-1" + in.AgentConfig = map[string]any{"agent_kind": "deepseek_harness", "model_id": "model-ds"} + opts := renderStaticAgentOptions(in) + + if err := c.injectManagedModel(context.Background(), in, opts, "deepseek_harness"); err != nil { + t.Fatalf("injectManagedModel: %v", err) + } + if got := opts["model"]; got != "deepseek-v4" { + t.Fatalf("opts[model] = %v, want deepseek-v4", got) + } + if _, ok := opts["dsh_provider"].(map[string]any); !ok { + t.Fatalf("opts[dsh_provider] must be set, got %T", opts["dsh_provider"]) + } + env, _ := opts["env"].(map[string]any) + if got := env["PARSAR_DSH_API_KEY"]; got != "sk-dsh-platform" { + t.Fatalf("env[PARSAR_DSH_API_KEY] = %v, want the decrypted key", got) + } +} diff --git a/server/internal/connector/agentdaemon/sandbox_seed.go b/server/internal/connector/agentdaemon/sandbox_seed.go index cd2e3afe..ad95a121 100644 --- a/server/internal/connector/agentdaemon/sandbox_seed.go +++ b/server/internal/connector/agentdaemon/sandbox_seed.go @@ -28,10 +28,11 @@ import ( type SandboxConnector string const ( - SandboxConnectorClaude SandboxConnector = "claude" - SandboxConnectorOpenCode SandboxConnector = "opencode" - SandboxConnectorCodex SandboxConnector = "codex" - SandboxConnectorPi SandboxConnector = "pi" + SandboxConnectorClaude SandboxConnector = "claude" + SandboxConnectorOpenCode SandboxConnector = "opencode" + SandboxConnectorCodex SandboxConnector = "codex" + SandboxConnectorPi SandboxConnector = "pi" + SandboxConnectorDeepseekHarness SandboxConnector = "deepseek-harness" ) // In-image absolute paths to the hook scripts baked by @@ -116,6 +117,8 @@ func ConnectorForAgentKind(agentKind string) SandboxConnector { return SandboxConnectorOpenCode case "pi": return SandboxConnectorPi + case "deepseek_harness": + return SandboxConnectorDeepseekHarness default: // claude_code, "", and anything unknown → Claude return SandboxConnectorClaude @@ -151,6 +154,11 @@ func seedPlatformConfig(ctx context.Context, client E2BClient, sb e2b.Sandbox, c // available in the image; daemon discovers and registers it // via heartbeat. return nil + case SandboxConnectorDeepseekHarness: + // dsh has no per-turn hook surface, and the daemon adapter + // prepends the spec/memory bundle to the task text instead, so + // nothing has to be seeded into the sandbox filesystem. + return nil default: return fmt.Errorf("sandbox_seed: unknown connector %q", conn) } diff --git a/server/internal/connector/agentdaemon/sandbox_seed_test.go b/server/internal/connector/agentdaemon/sandbox_seed_test.go index 150de20b..7fd723d7 100644 --- a/server/internal/connector/agentdaemon/sandbox_seed_test.go +++ b/server/internal/connector/agentdaemon/sandbox_seed_test.go @@ -79,11 +79,12 @@ func TestRenderClaudeSettings(t *testing.T) { // scripts would either error out or pick the wrong inject contract. func TestConnectorTagFor(t *testing.T) { cases := map[SandboxConnector]string{ - "": "claude", - SandboxConnectorClaude: "claude", - SandboxConnectorOpenCode: "opencode", - SandboxConnectorCodex: "codex", - SandboxConnectorPi: "pi", + "": "claude", + SandboxConnectorClaude: "claude", + SandboxConnectorOpenCode: "opencode", + SandboxConnectorCodex: "codex", + SandboxConnectorPi: "pi", + SandboxConnectorDeepseekHarness: "deepseek-harness", } for in, want := range cases { if got := connectorTagFor(in); got != want { @@ -100,13 +101,14 @@ func TestConnectorTagFor(t *testing.T) { // (e.g. "opencode" → "open_code") would have to touch this test too. func TestConnectorForAgentKind(t *testing.T) { cases := map[string]SandboxConnector{ - "": SandboxConnectorClaude, - "claude_code": SandboxConnectorClaude, - "codex": SandboxConnectorCodex, - "opencode": SandboxConnectorOpenCode, - "pi": SandboxConnectorPi, - " pi ": SandboxConnectorPi, // TrimSpace applied - "bogus": SandboxConnectorClaude, + "": SandboxConnectorClaude, + "claude_code": SandboxConnectorClaude, + "codex": SandboxConnectorCodex, + "opencode": SandboxConnectorOpenCode, + "pi": SandboxConnectorPi, + "deepseek_harness": SandboxConnectorDeepseekHarness, + " pi ": SandboxConnectorPi, // TrimSpace applied + "bogus": SandboxConnectorClaude, } for in, want := range cases { if got := ConnectorForAgentKind(in); got != want { @@ -133,6 +135,7 @@ func TestSeedPlatformConfig_DispatchTable(t *testing.T) { {"opencode noop until template exists", SandboxConnectorOpenCode, 0, false}, {"codex noop until template exists", SandboxConnectorCodex, 0, false}, {"pi noop until template exists", SandboxConnectorPi, 0, false}, + {"deepseek harness needs no seed", SandboxConnectorDeepseekHarness, 0, false}, {"unknown connector errors", SandboxConnector("totally-bogus"), 0, true}, } for _, tc := range cases { diff --git a/server/internal/connector/types.go b/server/internal/connector/types.go index 14e998df..2fc4e393 100644 --- a/server/internal/connector/types.go +++ b/server/internal/connector/types.go @@ -52,6 +52,12 @@ type PromptInput struct { AgentName string AgentSlug string + // TriggerMessageID is the persisted message id behind + // TriggerMessageContent. Empty for callers that synthesize a prompt + // without a stored message. Connectors use it to tell the current + // task apart from the conversation's stored transcript. + TriggerMessageID string + // TriggerMessageContent is the user-facing message that drives this // prompt. The connector decides how to fold it into its own prompt // schema. @@ -192,7 +198,7 @@ type PermissionRequest struct { // PermissionDecision is the human verdict for a PermissionRequest, // submitted via AgentConnector.SubmitPermission. type PermissionDecision struct { - RequestID string + RequestID string // DeliveryID is the caller's stable idempotency base. The agent-daemon // connector adds a unique suffix for each wire attempt before awaiting ack. DeliveryID string @@ -285,7 +291,7 @@ type PromptForUserChoiceQuestionAnswer struct { // - Cancelled=true marks a non-answer (timeout, /cancel) so the // daemon can emit a "stop, don't retry" tool_result. type PromptForUserChoiceDecision struct { - RequestID string + RequestID string // DeliveryID follows PermissionDecision's stable-base semantics. DeliveryID string DeviceID string diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index 3d891dd7..7d427755 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -710,6 +710,7 @@ select -- the config->>'runtime' connector override is dead. r.connector_type as connector_type, r.status, + coalesce(r.trigger_message_id::text, ''::text)::text as trigger_message_id, coalesce(m.content, ''::text)::text as trigger_message_content, coalesce(m.metadata, '{}'::jsonb)::jsonb as trigger_message_metadata, a.config::jsonb as agent_config, @@ -960,6 +961,28 @@ where m.conversation_id = @conversation_id::uuid order by m.created_at asc, m.id asc limit @item_limit; +-- name: ListRecentConversationMessages :many +-- Newest-first slice of the human/agent chat turns in one conversation. +-- Feeds the server-side history injection for daemon engines that cannot +-- resume their own session; ordered desc + limit so a long conversation +-- does not stream every row into the prompt path. +select + m.id::text, + m.sender_type, + coalesce(m.sender_id::text, ''::text)::text as m_sender_id, + m.content, + m.created_at +from messages m +join conversations c on c.id = m.conversation_id +where m.conversation_id = @conversation_id::uuid + and m.workspace_id = c.workspace_id + and m.deleted_at is null + and c.deleted_at is null + and m.kind = 'message' + and m.sender_type in ('user', 'agent', 'external') +order by m.created_at desc, m.id desc +limit @item_limit; + -- name: ListConversationAgentRuns :many select r.id::text, diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index 6f73a0ac..eafc03ab 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -4641,6 +4641,7 @@ select -- the config->>'runtime' connector override is dead. r.connector_type as connector_type, r.status, + coalesce(r.trigger_message_id::text, ''::text)::text as trigger_message_id, coalesce(m.content, ''::text)::text as trigger_message_content, coalesce(m.metadata, '{}'::jsonb)::jsonb as trigger_message_metadata, a.config::jsonb as agent_config, @@ -4681,6 +4682,7 @@ type GetAgentRunInvocationRow struct { RequestedByID string `json:"requested_by_id"` ConnectorType string `json:"connector_type"` Status string `json:"status"` + TriggerMessageID string `json:"trigger_message_id"` TriggerMessageContent string `json:"trigger_message_content"` TriggerMessageMetadata []byte `json:"trigger_message_metadata"` AgentConfig []byte `json:"agent_config"` @@ -4701,6 +4703,7 @@ func (q *Queries) GetAgentRunInvocation(ctx context.Context, id pgtype.UUID) (Ge &i.RequestedByID, &i.ConnectorType, &i.Status, + &i.TriggerMessageID, &i.TriggerMessageContent, &i.TriggerMessageMetadata, &i.AgentConfig, @@ -8732,6 +8735,68 @@ func (q *Queries) ListPendingWorkspaceInvitationsByInviter(ctx context.Context, return items, nil } +const listRecentConversationMessages = `-- name: ListRecentConversationMessages :many +select + m.id::text, + m.sender_type, + coalesce(m.sender_id::text, ''::text)::text as m_sender_id, + m.content, + m.created_at +from messages m +join conversations c on c.id = m.conversation_id +where m.conversation_id = $1::uuid + and m.workspace_id = c.workspace_id + and m.deleted_at is null + and c.deleted_at is null + and m.kind = 'message' + and m.sender_type in ('user', 'agent', 'external') +order by m.created_at desc, m.id desc +limit $2 +` + +type ListRecentConversationMessagesParams struct { + ConversationID pgtype.UUID `json:"conversation_id"` + ItemLimit int32 `json:"item_limit"` +} + +type ListRecentConversationMessagesRow struct { + MID string `json:"m_id"` + SenderType string `json:"sender_type"` + MSenderID string `json:"m_sender_id"` + Content string `json:"content"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +// Newest-first slice of the human/agent chat turns in one conversation. +// Feeds the server-side history injection for daemon engines that cannot +// resume their own session; ordered desc + limit so a long conversation +// does not stream every row into the prompt path. +func (q *Queries) ListRecentConversationMessages(ctx context.Context, arg ListRecentConversationMessagesParams) ([]ListRecentConversationMessagesRow, error) { + rows, err := q.db.Query(ctx, listRecentConversationMessages, arg.ConversationID, arg.ItemLimit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRecentConversationMessagesRow{} + for rows.Next() { + var i ListRecentConversationMessagesRow + if err := rows.Scan( + &i.MID, + &i.SenderType, + &i.MSenderID, + &i.Content, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listSandboxPoolEntriesDueForAutoRenew = `-- name: ListSandboxPoolEntriesDueForAutoRenew :many select sandbox_id, diff --git a/server/internal/dev/run_stream.go b/server/internal/dev/run_stream.go index 5e02d368..1a446b68 100644 --- a/server/internal/dev/run_stream.go +++ b/server/internal/dev/run_stream.go @@ -364,6 +364,7 @@ func dispatchConversationRun(ctx context.Context, runtimeStore RuntimeStore, cfg AgentName: invocation.AgentName, AgentSlug: invocation.AgentSlug, ConversationInitiatorID: userConversationInitiatorID(invocation), + TriggerMessageID: invocation.TriggerMessageID, TriggerMessageContent: invocation.TriggerMessageContent, TriggerAttachments: invocation.TriggerAttachments, AgentConfig: invocation.AgentConfig, diff --git a/server/internal/store/conversation_history.go b/server/internal/store/conversation_history.go new file mode 100644 index 00000000..c5b6f9e3 --- /dev/null +++ b/server/internal/store/conversation_history.go @@ -0,0 +1,52 @@ +package store + +import ( + "context" + "slices" + "time" + + "github.com/MiniMax-AI-Dev/parsar/server/internal/db/sqlc" +) + +// ConversationHistoryMessage is one human/agent chat turn, trimmed to the +// fields a prompt-side transcript needs. +type ConversationHistoryMessage struct { + ID string `json:"id"` + SenderType string `json:"sender_type"` + SenderID string `json:"sender_id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +// ListRecentConversationHistory returns the newest `limit` chat turns of a +// conversation in oldest-first order. The query selects newest-first so a +// long conversation only reads the tail; the slice is reversed here because +// every consumer renders the transcript in reading order. +func (s *Store) ListRecentConversationHistory(ctx context.Context, conversationID string, limit int32) ([]ConversationHistoryMessage, error) { + if limit <= 0 { + return nil, nil + } + conversationUUID, err := uuid(conversationID) + if err != nil { + return nil, err + } + rows, err := sqlc.New(s.db).ListRecentConversationMessages(ctx, sqlc.ListRecentConversationMessagesParams{ + ConversationID: conversationUUID, + ItemLimit: limit, + }) + if err != nil { + return nil, err + } + out := make([]ConversationHistoryMessage, 0, len(rows)) + for _, row := range rows { + out = append(out, ConversationHistoryMessage{ + ID: row.MID, + SenderType: row.SenderType, + SenderID: row.MSenderID, + Content: row.Content, + CreatedAt: row.CreatedAt.Time, + }) + } + slices.Reverse(out) + return out, nil +} diff --git a/server/internal/store/conversation_history_test.go b/server/internal/store/conversation_history_test.go new file mode 100644 index 00000000..f57e7f3a --- /dev/null +++ b/server/internal/store/conversation_history_test.go @@ -0,0 +1,99 @@ +package store + +import ( + "context" + "testing" +) + +// The prompt path reads this on every turn for engines that cannot resume, +// so the contract is narrow: newest turns only, oldest-first, human and +// agent chat turns only. +func TestListRecentConversationHistory(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + store := New(db) + ids := mustSeedDevFixture(t, ctx, store) + + send := func(content string) string { + t.Helper() + result, err := store.SendUserMessageToConversation(ctx, SendUserMessageToConversationInput{ + ConversationID: ids.ConversationID, + UserID: ids.UserID, + Content: content, + MentionedAgentIDs: []string{ids.ProductAgentID}, + }) + if err != nil { + t.Fatalf("send user message %q: %v", content, err) + } + if len(result.RunIDs) == 0 { + t.Fatalf("expected a run for %q", content) + } + return result.RunIDs[0] + } + + runID := send("@product-agent first question") + if _, err := store.SendAssistantMessageFromRun(ctx, SendAssistantMessageFromRunInput{ + RunID: runID, + Source: "agent", + Content: "first answer", + }); err != nil { + t.Fatalf("send assistant message: %v", err) + } + secondRunID := send("@product-agent second question") + + // A runtime_error notice is a system message, not a conversation turn: + // replaying it as history would teach the agent to answer Parsar's own + // plumbing messages. + if _, err := store.CreateRuntimeErrorSystemMessage(ctx, CreateRuntimeErrorSystemMessageInput{ + WorkspaceID: ids.WorkspaceID, + AgentID: ids.ProductAgentID, + RunID: secondRunID, + ConversationID: ids.ConversationID, + SubKind: "capability_credential_missing", + CapabilityID: "cap-1", + CapabilityName: "MCP · github", + CredentialKind: "github_token", + }); err != nil { + t.Fatalf("create runtime error system message: %v", err) + } + + history, err := store.ListRecentConversationHistory(ctx, ids.ConversationID, 10) + if err != nil { + t.Fatalf("list history: %v", err) + } + if len(history) != 3 { + t.Fatalf("history length = %d, want 3 (2 user + 1 agent): %+v", len(history), history) + } + wantContents := []string{"@product-agent first question", "first answer", "@product-agent second question"} + for i, want := range wantContents { + if history[i].Content != want { + t.Fatalf("history[%d].Content = %q, want %q (full: %+v)", i, history[i].Content, want, history) + } + } + if history[0].SenderType != "user" || history[1].SenderType != "agent" { + t.Fatalf("sender types = %q/%q, want user/agent", history[0].SenderType, history[1].SenderType) + } + if history[0].ID == "" || history[0].CreatedAt.IsZero() { + t.Fatalf("history rows must carry id + created_at: %+v", history[0]) + } + if history[0].CreatedAt.After(history[2].CreatedAt) { + t.Fatalf("rows must be oldest-first: %+v", history) + } + + // A limit keeps the tail, not the head: the newest turns are the ones + // the next answer depends on. + tail, err := store.ListRecentConversationHistory(ctx, ids.ConversationID, 2) + if err != nil { + t.Fatalf("list history with limit: %v", err) + } + if len(tail) != 2 { + t.Fatalf("limited history length = %d, want 2", len(tail)) + } + if tail[0].Content != "first answer" || tail[1].Content != "@product-agent second question" { + t.Fatalf("limit must keep the newest turns oldest-first, got %+v", tail) + } + + if zero, err := store.ListRecentConversationHistory(ctx, ids.ConversationID, 0); err != nil || zero != nil { + t.Fatalf("non-positive limit must read nothing: rows=%+v err=%v", zero, err) + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 8cfe74ea..11b1b0a4 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -384,16 +384,20 @@ type DeleteAgentResult struct { type HTTPAgentRunInvocation = AgentRunInvocation type AgentRunInvocation struct { - RunID string `json:"run_id"` - WorkspaceID string `json:"workspace_id"` - ConversationID string `json:"conversation_id"` - AgentID string `json:"agent_id"` - AgentName string `json:"agent_name"` - AgentSlug string `json:"agent_slug"` - RequestedByType string `json:"requested_by_type"` - RequestedByID string `json:"requested_by_id"` - ConnectorType string `json:"connector_type"` - Status string `json:"status"` + RunID string `json:"run_id"` + WorkspaceID string `json:"workspace_id"` + ConversationID string `json:"conversation_id"` + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name"` + AgentSlug string `json:"agent_slug"` + RequestedByType string `json:"requested_by_type"` + RequestedByID string `json:"requested_by_id"` + ConnectorType string `json:"connector_type"` + Status string `json:"status"` + // TriggerMessageID identifies the persisted message that started this + // run, so a prompt-side transcript can exclude it instead of echoing + // the task back to the engine. + TriggerMessageID string `json:"trigger_message_id,omitempty"` TriggerMessageContent string `json:"trigger_message_content"` // TriggerAttachments carries non-text payloads alongside // TriggerMessageContent. Connectors that don't forward attachments @@ -2067,6 +2071,7 @@ func (s *Store) GetAgentRunInvocation(ctx context.Context, runID string) (AgentR RequestedByID: row.RequestedByID, ConnectorType: row.ConnectorType, Status: row.Status, + TriggerMessageID: row.TriggerMessageID, TriggerMessageContent: applyTriggerMessagePrefix(triggerMetadata, row.TriggerMessageContent), TriggerAttachments: DecodeMessageAttachments(triggerMetadata), AgentConfig: mergeRuntimeIntoAgentConfig(decodeJSONMap(row.AgentConfig), row.RuntimeID),