Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions apps/parsar-daemon/internal/agent/deepseekharness/export_test.go
Original file line number Diff line number Diff line change
@@ -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
)
175 changes: 175 additions & 0 deletions apps/parsar-daemon/internal/agent/deepseekharness/options.go
Original file line number Diff line number Diff line change
@@ -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 <task>` 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)
}
Loading
Loading