From 5fd0f9007386abdb357c0d922f65d3e4e911c3dd Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 19 Sep 2026 13:15:32 +0800 Subject: [PATCH 1/3] Add MiniMax Code text execution through the harness contract --- CONTRIBUTING.md | 13 +- .../internal/agent/mcode/events.go | 3 + .../internal/agent/mcode/execution.go | 64 ++++++ .../internal/agent/mcode/execution_test.go | 77 +++++++ .../agent/mcode/native_history_test.go | 66 ++++++ .../internal/agent/mcode/options.go | 16 +- .../internal/agent/mcode/session.go | 74 ++++++- .../internal/agent/mcode/session_test.go | 38 +++- .../internal/agent/mcode/steering.go | 100 +++++++++ .../internal/agent/mcode/steering_test.go | 126 +++++++++++ apps/parsar-daemon/internal/cli/mcode.go | 9 + .../internal/cli/mcode_execution_test.go | 26 +++ contracts/agents-api/harness-onboarding.md | 140 ++++++++++++ contracts/agents-api/harnesses.md | 3 + services/agents-api/deploy/mcode/README.md | 122 +++++++++++ services/agents-api/internal/engine/mcode.go | 23 ++ .../agents-api/internal/engine/profile.go | 1 + .../internal/execution/mcode_profile_test.go | 25 +++ .../store/mcode_public_native_test.go | 202 ++++++++++++++++++ .../agents-api/tests/official_mcode_native.py | 95 ++++++++ 20 files changed, 1209 insertions(+), 14 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/mcode/execution.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/execution_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/native_history_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/steering.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/steering_test.go create mode 100644 apps/parsar-daemon/internal/cli/mcode_execution_test.go create mode 100644 contracts/agents-api/harness-onboarding.md create mode 100644 services/agents-api/deploy/mcode/README.md create mode 100644 services/agents-api/internal/engine/mcode.go create mode 100644 services/agents-api/internal/execution/mcode_profile_test.go create mode 100644 services/agents-api/internal/store/mcode_public_native_test.go create mode 100644 services/agents-api/tests/official_mcode_native.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 121fd2b0..be2f332a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1970,7 +1970,18 @@ compatibility fallback is permitted. The synthetic third-harness acceptance unde `services/agents-api/internal/store` exercises the actual gateway and daemon router; its fixture under `apps/parsar-daemon/testdata` is never a production engine. See [the integration guide](contracts/agents-api/harnesses.md) for contract and -operation-specific acceptance. +operation-specific acceptance and the [onboarding reference](contracts/agents-api/harness-onboarding.md) +for implementation and registration steps. + +MiniMax Code's opt-in Agents API profile qualifies native ACP 0.4.12 for +`environment:none` text execution. It reuses the shared lifecycle without public +workspace, functions, MCP or native Subagents. Keep the ordinary product package +pin unchanged. Native configuration disables file/shell authority and external +capability discovery; the child receives a private Session home and a restricted +environment. Active-input application requires a native ACP receipt, cancellation +settles the process and output, and continuation requires the exact owned native +history. Do not infer history IDs or qualify hosted execution from this text +profile. See [deployment and acceptance](services/agents-api/deploy/mcode/README.md). Claude hosted functions compose the existing SDK function bridge with the native workspace sandbox. Only declared function tools and the verified native tool diff --git a/apps/parsar-daemon/internal/agent/mcode/events.go b/apps/parsar-daemon/internal/agent/mcode/events.go index b4348ba0..ac7a63df 100644 --- a/apps/parsar-daemon/internal/agent/mcode/events.go +++ b/apps/parsar-daemon/internal/agent/mcode/events.go @@ -31,6 +31,9 @@ func (s *Session) handle(frame rpcFrame) error { if event.SessionID != s.sessionID { return nil } + s.mu.Lock() + s.steeringReady = true + s.mu.Unlock() switch event.Update.Kind { case "agent_message_chunk": if event.Update.Content.Type != "text" { diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go new file mode 100644 index 00000000..b1f5ea2d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/execution.go @@ -0,0 +1,64 @@ +package mcode + +import ( + "fmt" + "os" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const ExecutionVersion = "0.4.12" + +// SupportsExecution is an operator opt-in, separate from ordinary product availability. +func SupportsExecution(version string) bool { + return os.Getenv("PARSAR_MCODE_AGENTS_API") == "1" && version == ExecutionVersion +} + +func validateExecutionRequest(req proto.PromptRequestPayload) error { + if !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || !req.DisableSubagents || req.WorkDir != "" || req.AgentStateKey == "" || req.RemoteEnvironment != nil || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { + return fmt.Errorf("mcode: unsupported execution configuration") + } + if req.ExecutionControls == nil || req.ExecutionControls.WebSearch != "disabled" || (req.ExecutionControls.TextVerbosity != "" && req.ExecutionControls.TextVerbosity != "medium") { + return fmt.Errorf("mcode: unsupported execution controls") + } + if mode := optionString(req.AgentOptions, "mode"); mode != "" { + return fmt.Errorf("mcode: text execution uses default native permissions") + } + if req.AgentOptions["plugins"] != nil { + return fmt.Errorf("mcode: execution cannot import plugins") + } + if req.AgentOptions["skills"] != nil || req.AgentOptions["mcp_servers"] != nil || req.AgentOptions["env"] != nil { + return fmt.Errorf("mcode: execution cannot import product capabilities or environment") + } + return nil +} + +func configureTextExecution(config map[string]any) { + config["agents"] = map[string]any{"default": map[string]any{ + "tools": []string{}, "builtinTools": []string{}, "skills": []string{}, + "features": map[string]bool{"mavis": false, "delegation": false, "webSearch": false}, + }} + config["askUser"] = map[string]bool{"enabled": false} + config["beta"] = map[string]bool{"browserUseTooling": false, "mcodeTools": false, "threadGoal": false} +} + +// Only process and model-network essentials cross into the native child. +func executionEnvironment() []string { + var env []string + for _, key := range []string{"PATH", "LANG", "LC_ALL", "TMPDIR", "TMP", "TEMP", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"} { + if value, ok := os.LookupEnv(key); ok { + env = append(env, key+"="+value) + } + } + return env +} + +// ACP commands are only recognized for a single text block. Public input must +// remain user text; ordinary product Sessions retain their native command behavior. +func promptContent(text string, public bool) []map[string]string { + blocks := []map[string]string{{"type": "text", "text": text}} + if public { + blocks = append(blocks, map[string]string{"type": "text", "text": ""}) + } + return blocks +} diff --git a/apps/parsar-daemon/internal/agent/mcode/execution_test.go b/apps/parsar-daemon/internal/agent/mcode/execution_test.go new file mode 100644 index 00000000..f9b4980f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/execution_test.go @@ -0,0 +1,77 @@ +package mcode + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func executionRequest(t *testing.T) proto.PromptRequestPayload { + r := testRequest(t) + r.StrictResume, r.ReleaseOnCompletion, r.DisableExecutionEnvironment, r.DisableSubagents = true, true, true, true + r.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"} + return r +} + +func TestExecutionOptionsExcludeAmbientAuthority(t *testing.T) { + r := executionRequest(t) + t.Setenv("AGENTS_API_SECRET_CANARY", "secret") + t.Setenv("NODE_OPTIONS", "--import=untrusted") + opts, err := prepareOptions(t.Context(), r) + if err != nil { + t.Fatal(err) + } + for _, e := range opts.Env { + if strings.HasPrefix(e, "AGENTS_API_SECRET_CANARY=") || strings.HasPrefix(e, "NODE_OPTIONS=") { + t.Fatal("ambient authority inherited") + } + } + data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if json.Unmarshal(data, &config) != nil { + t.Fatal("bad config") + } + features := config["agents"].(map[string]any)["default"].(map[string]any)["features"].(map[string]any) + for _, k := range []string{"mavis", "delegation", "webSearch"} { + if features[k] != false { + t.Fatalf("%s remains enabled", k) + } + } +} + +func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { + for _, change := range []func(*proto.PromptRequestPayload){ + func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = false }, + func(r *proto.PromptRequestPayload) { r.DisableSubagents = false }, + func(r *proto.PromptRequestPayload) { r.RequireExistingNativeSession = true }, + func(r *proto.PromptRequestPayload) { r.WorkDir = "/tmp" }, + func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "f"}} }, + func(r *proto.PromptRequestPayload) { r.ExecutionControls.WebSearch = "enabled" }, + func(r *proto.PromptRequestPayload) { r.AgentOptions["env"] = map[string]any{"X": "Y"} }, + } { + r := executionRequest(t) + change(&r) + if _, err := prepareOptions(t.Context(), r); err == nil { + t.Fatal("unsupported execution accepted") + } + } +} + +func TestPublicTextDoesNotInvokeACPCommands(t *testing.T) { + for _, text := range []string{"/model", "/compact", "hello"} { + blocks := promptContent(text, true) + if len(blocks) != 2 || blocks[0]["text"] != text || blocks[1]["text"] != "" { + t.Fatal(blocks) + } + if blocks = promptContent(text, false); len(blocks) != 1 || blocks[0]["text"] != text { + t.Fatal("product prompt changed") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/native_history_test.go b/apps/parsar-daemon/internal/agent/mcode/native_history_test.go new file mode 100644 index 00000000..280bd588 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/native_history_test.go @@ -0,0 +1,66 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// A successful public real-model run supplies an actual foreign native ID. +// Neither that history nor a missing ID may silently become a new session. +func TestNativeMCodeHistoryIsolation(t *testing.T) { + binary, options, foreign := os.Getenv("PARSAR_MCODE_BIN"), os.Getenv("PARSAR_MCODE_REAL_OPTIONS"), os.Getenv("PARSAR_MCODE_FOREIGN_NATIVE_ID") + if binary == "" || options == "" || foreign == "" { + t.Skip("native executable, private provider options and foreign history ID required") + } + raw, err := os.ReadFile(options) + if err != nil { + t.Fatal(err) + } + for name, id := range map[string]string{"foreign": foreign, "missing": "00000000-0000-4000-8000-000000000000"} { + t.Run(name, func(t *testing.T) { + req := executionRequest(t) + if json.Unmarshal(raw, &req.AgentOptions) != nil { + t.Fatal("invalid private options") + } + req.AgentSessionID, req.Prompt = id, "This input must never execute." + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + out := make(chan proto.Envelope, 64) + session, err := newSession(ctx, req, out, binary) + if err != nil { + t.Fatal(err) + } + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + if err := session.Cancel(cleanup); err != nil { + t.Error(err) + } + }() + rejected := false + for event := range out { + if event.Type == proto.TypeError { + var failure proto.ErrorPayload + _ = json.Unmarshal(event.Payload, &failure) + rejected = strings.Contains(failure.Error, "session/load:") && !strings.Contains(failure.Error, "deadline exceeded") + } + if event.Type == proto.TypeDone { + var done proto.DonePayload + _ = json.Unmarshal(event.Payload, &done) + if done.Content != "" || done.Metadata[proto.DoneMetaAgentSessionID] != nil { + t.Fatal("unowned history executed or silently replaced") + } + } + } + if !rejected { + t.Fatal("native history was not explicitly rejected") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/options.go b/apps/parsar-daemon/internal/agent/mcode/options.go index 7bcde4c4..80d5c2af 100644 --- a/apps/parsar-daemon/internal/agent/mcode/options.go +++ b/apps/parsar-daemon/internal/agent/mcode/options.go @@ -23,6 +23,11 @@ type launchOptions struct { func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { var result launchOptions + if req.StrictResume { + if err := validateExecutionRequest(req); err != nil { + return result, err + } + } if len(req.Attachments) > 0 { return result, fmt.Errorf("mcode: ACP does not support attachments") } @@ -71,6 +76,9 @@ func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launch if result.Model == "" { return result, fmt.Errorf("mcode: model is required") } + if req.StrictResume { + configureTextExecution(config) + } data, err := json.Marshal(config) if err != nil { return result, err @@ -79,7 +87,10 @@ func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launch return result, err } result.Env = append([]string{}, os.Environ()...) - if raw := opts["env"]; raw != nil { + if req.StrictResume { + result.Env = executionEnvironment() + } + if raw := opts["env"]; raw != nil && !req.StrictResume { env, ok := raw.(map[string]any) if !ok { return result, fmt.Errorf("mcode: env must be an object") @@ -94,6 +105,9 @@ func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launch } // The adapter owns the native state location, including after cold resume. result.Env = append(result.Env, "MINIMAX_DATA_DIR="+result.DataDir) + if req.StrictResume { + result.Env = append(result.Env, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) + } result.Mode = optionString(opts, "mode") if result.Mode == "" { result.Mode = "auto" diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go index dc407310..cd43fcef 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session.go +++ b/apps/parsar-daemon/internal/agent/mcode/session.go @@ -2,7 +2,6 @@ package mcode import ( "bufio" - "bytes" "context" "encoding/json" "errors" @@ -36,6 +35,9 @@ type Session struct { questions map[string]pendingQuestion exitErr error nextID int + responses map[string]chan rpcFrame + steeringReady bool + steeringTurn string sequence uint64 active bool content strings.Builder @@ -60,11 +62,11 @@ func newSession(ctx context.Context, req proto.PromptRequestPayload, out chan<- if err != nil { return nil, err } - process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true}) + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true, OwnProcessGroup: req.StrictResume}) if err != nil { return nil, err } - s := &Session{ctx: ctx, req: req, opts: opts, process: process, out: out, frames: make(chan rpcFrame, 32), exited: make(chan struct{}), finished: make(chan struct{}), permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + s := &Session{ctx: ctx, req: req, opts: opts, process: process, out: out, frames: make(chan rpcFrame, 32), exited: make(chan struct{}), finished: make(chan struct{}), responses: map[string]chan rpcFrame{}, permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} go func() { _, _ = io.Copy(io.Discard, process.Stderr) }() go s.read() go s.run() @@ -84,6 +86,18 @@ func (s *Session) read() { s.process.Cancel() break } + if frame.Method == "" { + s.mu.Lock() + response := s.responses[string(frame.ID)] + s.mu.Unlock() + if response != nil { + select { + case response <- frame: + default: + } + continue + } + } select { case s.frames <- frame: case <-s.finished: @@ -106,6 +120,9 @@ func (s *Session) run() { defer close(s.out) defer close(s.finished) err := s.execute() + s.mu.Lock() + s.steeringReady = false + s.mu.Unlock() if err != nil { s.process.Cancel() s.emit(proto.TypeError, proto.ErrorPayload{Error: err.Error()}) @@ -161,7 +178,11 @@ func (s *Session) execute() error { if err != nil { return err } - for _, config := range []struct{ key, value string }{{"model", model}, {"permissionMode", s.opts.Mode}} { + settings := []struct{ key, value string }{{"model", model}} + if !s.req.StrictResume { + settings = append(settings, struct{ key, value string }{"permissionMode", s.opts.Mode}) + } + for _, config := range settings { if err := s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": config.key, "value": config.value}, nil, false); err != nil { return err } @@ -170,8 +191,11 @@ func (s *Session) execute() error { var result struct { StopReason string `json:"stopReason"` } - err = s.call("session/prompt", map[string]any{"sessionId": session.SessionID, "prompt": []map[string]string{{"type": "text", "text": s.req.Prompt}}}, &result, true) + err = s.call("session/prompt", map[string]any{"sessionId": session.SessionID, "prompt": promptContent(s.req.Prompt, s.req.StrictResume)}, &result, true) s.active = false + s.mu.Lock() + s.steeringReady = false + s.mu.Unlock() if err != nil { return err } @@ -209,13 +233,13 @@ func advertisedModel(options []configOption, model string) (string, error) { } func (s *Session) call(method string, params any, result any, prompt bool) error { - s.nextID++ - id := json.RawMessage(strconv.Itoa(s.nextID)) + id, _ := s.reserveResponse() + s.removeResponse(id) raw, err := json.Marshal(params) if err != nil { return err } - if err := s.write(rpcFrame{JSONRPC: "2.0", ID: id, Method: method, Params: raw}); err != nil { + if err := s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}); err != nil { return err } ctx := s.process.Context() @@ -239,7 +263,7 @@ func (s *Session) call(method string, params any, result any, prompt bool) error } continue } - if !bytes.Equal(frame.ID, id) { + if string(frame.ID) != id { continue } if frame.Error != nil { @@ -272,9 +296,22 @@ func (s *Session) emit(kind string, payload any) { } } -func (s *Session) Cancel(context.Context) error { +func (s *Session) Cancel(ctx context.Context) error { s.process.Cancel() - return nil + if !s.req.StrictResume { + return nil + } + select { + case <-s.exited: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-s.finished: + return nil + case <-ctx.Done(): + return ctx.Err() + } } func (s *Session) SubmitPermission(_ context.Context, id string, decision proto.PermissionDecisionPayload) error { @@ -301,3 +338,18 @@ func (s *Session) SubmitPermission(_ context.Context, id string, decision proto. delete(s.permissions, id) return nil } + +func (s *Session) reserveResponse() (string, chan rpcFrame) { + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + id := strconv.Itoa(s.nextID) + reply := make(chan rpcFrame, 1) + s.responses[id] = reply + return id, reply +} +func (s *Session) removeResponse(id string) { + s.mu.Lock() + delete(s.responses, id) + s.mu.Unlock() +} diff --git a/apps/parsar-daemon/internal/agent/mcode/session_test.go b/apps/parsar-daemon/internal/agent/mcode/session_test.go index 456ddae4..3e0092b5 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/session_test.go @@ -26,6 +26,9 @@ func testRequest(t *testing.T) proto.PromptRequestPayload { func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { t.Helper() req := testRequest(t) + if scenario == "strict-cancel" { + req = executionRequest(t) + } if resume { req.AgentSessionID = "native-1" } @@ -35,7 +38,7 @@ func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan t.Fatal(err) } binary := filepath.Join(t.TempDir(), "mcode") - script := "#!/bin/sh\nexec '" + strings.ReplaceAll(exe, "'", "'\\''") + "' -test.run=^TestMCodeProcess$ -- \"$@\"\n" + script := "#!/bin/sh\nexport PARSAR_MCODE_TEST_HELPER=" + scenario + "\nexec '" + strings.ReplaceAll(exe, "'", "'\\''") + "' -test.run=^TestMCodeProcess$ -- \"$@\"\n" if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } @@ -233,6 +236,25 @@ func TestMCodeProcess(t *testing.T) { os.Exit(4) } case "session/prompt": + var input struct { + Prompt []map[string]string `json:"prompt"` + } + _ = json.Unmarshal(frame.Params, &input) + if (scenario == "strict-cancel" && len(input.Prompt) != 2) || (scenario != "strict-cancel" && len(input.Prompt) != 1) { + os.Exit(9) + } + if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "strict-cancel" { + promptID = frame.ID + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) + continue + } + if scenario == "many-frames" { + for range 100 { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "x"}}) + } + result = map[string]string{"stopReason": "end_turn"} + break + } if scenario == "rpc-error" { send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32603, Message: "Fixture provider unavailable"}}) continue @@ -250,6 +272,20 @@ func TestMCodeProcess(t *testing.T) { } update("usage_update", map[string]any{"used": 2000, "size": 64000, "cost": map[string]any{"amount": 2, "currency": "USD"}}) result = map[string]string{"stopReason": "end_turn"} + case "mcode/session/steer": + if scenario == "steer-lost" { + os.Exit(0) + } + if scenario == "steer-rejected" { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32602, Message: "inactive"}}) + continue + } + raw, _ := json.Marshal(map[string]string{"turnId": "native-turn", "mode": "steered"}) + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "-steered"}}) + raw, _ = json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) + continue case "": if string(frame.ID) == `"permission-1"` { var reply struct { diff --git a/apps/parsar-daemon/internal/agent/mcode/steering.go b/apps/parsar-daemon/internal/agent/mcode/steering.go new file mode 100644 index 00000000..994ae4a0 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/steering.go @@ -0,0 +1,100 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var _ agent.DurableSteerer = (*Session)(nil) + +func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return s.SteerWithReceipt(ctx, input, nil) +} + +// Native acceptance belongs to the active ACP Turn; it does not promise model consumption. +func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + if strings.TrimSpace(input.InputID) == "" || strings.TrimSpace(input.Text) == "" { + return agent.ErrSteeringRejected + } + s.mu.Lock() + ready, native := s.steeringReady, s.sessionID + s.mu.Unlock() + if s.process.Context().Err() != nil { + return agent.ErrSteeringInactive + } + if !ready || native == "" { + return agent.ErrSteeringNotReady + } + id, response := s.reserveResponse() + defer s.removeResponse(id) + raw, err := json.Marshal(map[string]string{"sessionId": native, "text": input.Text, "clientRequestId": input.InputID}) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + stop := context.AfterFunc(ctx, s.process.Cancel) + err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/steer", Params: raw}) + stop() + if err != nil { + return fmt.Errorf("mcode: input transport failed") + } + if written != nil { + written() + } + var frame rpcFrame + var stopped error + select { + case frame = <-response: + case <-s.finished: + stopped = fmt.Errorf("mcode: run ended with unknown input outcome") + case <-s.exited: + stopped = fmt.Errorf("mcode: input transport closed") + case <-ctx.Done(): + stopped = ctx.Err() + } + if stopped != nil { + // A native receipt already read before terminal closure remains authoritative. + select { + case frame = <-response: + default: + return stopped + } + } + if frame.Error != nil { + if frame.Error.Code == -32602 || frame.Error.Code == -32601 { + return agent.ErrSteeringRejected + } + return fmt.Errorf("mcode: native steering failed with unknown input outcome") + } + var result struct { + TurnID string `json:"turnId"` + Mode string `json:"mode"` + } + if json.Unmarshal(frame.Result, &result) != nil || result.TurnID == "" || (result.Mode != "steered" && result.Mode != "duplicate") { + return fmt.Errorf("mcode: invalid steering receipt") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.steeringTurn != "" && s.steeringTurn != result.TurnID { + return fmt.Errorf("mcode: steering turn changed") + } + s.steeringTurn = result.TurnID + return nil +} + +func (s *Session) CancellationOutcome() proto.DonePayload { + s.mu.Lock() + defer s.mu.Unlock() + metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} + if s.sessionID != "" { + metadata[proto.DoneMetaAgentSessionID] = s.sessionID + } + return proto.DonePayload{Metadata: metadata} +} diff --git a/apps/parsar-daemon/internal/agent/mcode/steering_test.go b/apps/parsar-daemon/internal/agent/mcode/steering_test.go new file mode 100644 index 00000000..b5bce7af --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/steering_test.go @@ -0,0 +1,126 @@ +package mcode + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestNativeSteeringReceipt(t *testing.T) { + for _, scenario := range []string{"steering", "steer-rejected", "steer-lost"} { + t.Run(scenario, func(t *testing.T) { + s, out := helperSession(t, scenario, false) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + select { + case event := <-out: + if event.Type != proto.TypeDelta { + t.Fatalf("first event %s", event.Type) + } + case <-ctx.Done(): + t.Fatal("not ready") + } + written := false + reply := make(chan error, 1) + go func() { + reply <- s.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "input-1", Text: "next"}, func() { written = true }) + }() + // Drain native output concurrently, as the router does. + drained := make(chan struct{}) + go func() { + for range out { + } + close(drained) + }() + err := <-reply + if !written { + t.Fatal("complete write was not reported") + } + switch scenario { + case "steering": + if err != nil { + t.Fatal(err) + } + case "steer-rejected": + if !errors.Is(err, agent.ErrSteeringRejected) { + t.Fatalf("got %v", err) + } + case "steer-lost": + if err == nil || errors.Is(err, agent.ErrSteeringRejected) { + t.Fatalf("unknown outcome became rejection/success: %v", err) + } + } + s.Cancel(ctx) + select { + case <-drained: + case <-ctx.Done(): + t.Fatal("output not closed") + } + }) + } +} + +func TestTerminalFollowsAllNativeFrames(t *testing.T) { + _, out := helperSession(t, "many-frames", false) + count := 0 + for e := range out { + switch e.Type { + case proto.TypeDelta: + count++ + case proto.TypeDone: + var done proto.DonePayload + _ = json.Unmarshal(e.Payload, &done) + if count != 100 || len(done.Content) != 100 { + t.Fatalf("terminal overtook frames: %d/%d", count, len(done.Content)) + } + case proto.TypeError: + t.Fatalf("unexpected error %s", e.Payload) + } + } +} + +func TestExecutionCancellationWaitsForOutputAndProcess(t *testing.T) { + s, out := helperSession(t, "strict-cancel", false) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + select { + case <-out: + case <-ctx.Done(): + t.Fatal("not ready") + } + drained := make(chan struct{}) + go func() { + for range out { + } + close(drained) + }() + if err := s.Cancel(ctx); err != nil { + t.Fatal(err) + } + select { + case <-s.exited: + default: + t.Fatal("cancel returned before process exit") + } + select { + case <-s.finished: + default: + t.Fatal("cancel returned before output settlement") + } + if err := s.Cancel(ctx); err != nil { + t.Fatal("duplicate cancellation", err) + } + select { + case <-drained: + case <-ctx.Done(): + t.Fatal("output not closed") + } + if s.CancellationOutcome().Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatal("lost native binding") + } +} diff --git a/apps/parsar-daemon/internal/cli/mcode.go b/apps/parsar-daemon/internal/cli/mcode.go index 92b07e38..48e05d6e 100644 --- a/apps/parsar-daemon/internal/cli/mcode.go +++ b/apps/parsar-daemon/internal/cli/mcode.go @@ -21,6 +21,15 @@ func discoverMCode(rc *runContext, check func(context.Context, string) (string, return result } result.Available, result.Version = true, version + if mcode.SupportsExecution(version) { + result.Capabilities.Steering = true + result.Capabilities.DurableTurns = true + result.Capabilities.DurableInputReceipts = true + result.Capabilities.ExecutionControls = true + result.Capabilities.ToolObservations = true + result.Capabilities.SubagentControl = true + result.Capabilities.EnvironmentNone = true + } fmt.Fprintf(rc.stdout, "mcode preflight ok (%s)\n", version) return result } diff --git a/apps/parsar-daemon/internal/cli/mcode_execution_test.go b/apps/parsar-daemon/internal/cli/mcode_execution_test.go new file mode 100644 index 00000000..d4197ad4 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/mcode_execution_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "context" + "io" + "testing" +) + +func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { + for _, tc := range []struct { + enabled, version string + qualified bool + }{{"", "0.4.12", false}, {"1", "0.3.11", false}, {"1", "0.4.12", true}} { + t.Run(tc.enabled+"/"+tc.version, func(t *testing.T) { + t.Setenv("PARSAR_MCODE_AGENTS_API", tc.enabled) + rc := &runContext{stdout: io.Discard, stderr: io.Discard} + info := discoverMCode(rc, func(context.Context, string) (string, error) { return tc.version, nil }) + if !info.Available || info.Capabilities.EnvironmentNone != tc.qualified || info.Capabilities.DurableInputReceipts != tc.qualified { + t.Fatalf("capabilities=%+v", info.Capabilities) + } + if info.Capabilities.NativeSessionRecovery || info.Capabilities.LocalEnvironment || info.Capabilities.FunctionTools { + t.Fatal("unqualified capability advertised") + } + }) + } +} diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md new file mode 100644 index 00000000..6a3b6359 --- /dev/null +++ b/contracts/agents-api/harness-onboarding.md @@ -0,0 +1,140 @@ +# Add a native harness to Agent Core + +This reference is for adapter developers using the shared contract on main after +PR #701 (2026-09-19). Start with a working native SDK or machine-readable protocol. +The goal is to register an engine without changing public handlers, storage or the +scheduler. Codex, Claude and additional harnesses have equal architectural status; +each engine qualifies its own supported operations. + +The [harness contract](harnesses.md) is the semantic baseline. The +[contributor guide](../../CONTRIBUTING.md#harness-qualification-and-onboarding) +owns architecture and delivery rules. The pinned external Agents API in +[upstream.json](upstream.json) remains separate from this private adapter contract. + +## Ownership and implementation locations + +| Component | Responsibility | Existing location | +| --- | --- | --- | +| Core | Public protocol, authentication, resource ownership, durable state and scheduling | `services/agents-api` | +| Runtime | Authenticated connection, dispatch, input receipts and preparation ownership | `apps/parsar-daemon/internal/dispatch` | +| Adapter | Native configuration, process/SDK calls, event translation and native restrictions | `apps/parsar-daemon/internal/agent/` | +| Harness | Model/tool loop and native history | Pinned upstream SDK or executable | +| Service profile | Pure validation of qualified placements and option values | `services/agents-api/internal/engine` | +| Runtime registration | Factory and verified capabilities available in this installation | `apps/parsar-daemon/internal/cli` | + +A service profile authorizes supported combinations. A Runtime advertisement says +what that installation can execute. Neither replaces public schema validation, +tenant authorization or the other boundary. Native limits belong in adapters and +profiles, not engine-name branches in Core. + +## Implement the existing interfaces + +Use `internal/agentdaemon/proto` for requests, events, receipts and errors. Do not +introduce a parallel wire protocol or another model/tool loop. + +| Interface | When required | Observable obligation | +| --- | --- | --- | +| `agent.Factory` and `agent.Session` | Every harness | Start a run; cancel idempotently; own output closure and native process lifetime | +| `agent.DurableSteerer` | Current public text execution contract | Distinguish a complete native write from confirmed application; return rejection/inactive/not-ready accurately | +| `agent.PreparationFactory` and `agent.Prepared` | Placements requiring preparation | Prepare without consuming model input; transfer ownership once in Start; retain failed cleanup ownership | +| `agent.PreparedCancellation` | Executable preparations | Cancel the same resource across Start; settle only after native effects and output writes stop | +| `agent.PermissionResponder`, `agent.UserChoiceResponder` | Only when emitting those interactions | Route exact interaction identities and preserve application receipts | +| Other optional interfaces | Only for declared operations | Implement the existing operation semantics and validate native support | + +Read the source comments in `registry.go`, `steering.go`, `preparation.go` and +`interactions.go` before implementing. Base `Session.Cancel` is a cancellation +signal; its return alone is not proof that all effects stopped. Use the existing +router's settlement path and adapter outcome interfaces. Do not give both the +adapter and router ownership of closing the same output channel. + +Preserve event order and native call identities. Emit one run terminal outcome; +never manufacture applied input, usage counters or successful cleanup. Resume only +the native history bound to the execution Session. Missing or ambiguous history +fails before new model input. A disconnected observer does not authorize replay. + +## Register a supported operation set + +1. Pin the upstream source/package version and document the native entry point. +2. Implement the adapter using its SDK or native protocol. Reuse shared process, + credential/configuration and local workspace helpers where applicable. +3. Register its `SupportedAgentKind` and factory with `RegisterKind`; register + preparation afterward with `RegisterPreparation` when supported. Runtime + advertisements must describe behavior verified for that installation. +4. Add a profile to the existing static catalog, or supply an immutable catalog + through service composition. Custom composition supplies the same + `execution.Policy` to `api.WithExecutionPolicy` and `Dispatcher.Policy`. +5. Package the native prerequisites and select the engine through operator + configuration (`AGENTS_API_ENGINE`). Do not invent a public harness field. + +The static registration surface requires a build. Dynamic plugins are outside +this contract. A small adapter does not remove the need for native qualification. +The runnable test-only example is +[`testdata/onboarding/main.go`](../../apps/parsar-daemon/testdata/onboarding/main.go). +It registers a text-only synthetic harness and is never shipped as a real engine. + +## Required behavior versus optional operations + +The current public text path requires durable turns, applied input receipts, +ordered observations, cancellation and enforcement of disabled execution controls. +Check `execution.Policy.engineCapabilities` for the exact current requirements. +An engine without native tools can guarantee their absence; an engine with tools +must actually disable them when requested. Configuration acceptance is not proof +of enforcement. + +MCP, public function calls, image inputs, verbosity controls and other optional +operations do not need to match another engine. Reject unqualified combinations +explicitly and record the gap. Never advertise a capability to bypass selection. + +Hosted workspace execution additionally requires verified preparation, workspace +reads/output export, network behavior and credential/history isolation. Reuse the +same dedicated Runtime binding and shared Files helpers. A native Bash sandbox +alone does not establish isolation for other native file tools. Enable a placement +only after its required security and lifecycle behavior is demonstrated. + +## Acceptance and delivery + +Before implementation, record the operation set, expected results, exclusions and +stopping conditions in the board. A batch ends when its declared operations pass; +it does not expand to match another harness's feature list. + +- Adapter tests: native failures, ordered events, input write/application receipts, + cancellation settlement, strict continuation and unknown-outcome handling. +- Shared integration: public admission, actual Worker/device selection, gateway, + daemon registration/dispatch and durable terminal projection. See + `TestThirdHarnessPublicOnboarding` for a synthetic example, not native evidence. +- Real acceptance: pinned official Python SDK and raw HTTP against our Agents API, + real provider API, native harness and independent execution database. Verify + initial execution, follow-up input, cancellation and restart/continuation. + For hosted qualification also verify Files/Artifacts, workspace identity, + credential protection and foreign-history rejection. +- Regression: existing qualified engines keep working. Run targeted tests during + development, then `make check` and applicable real regressions. API changes + require `make openapi`; query changes require `make sqlc-generate`. +- Review: use a fresh independent Astra high reviewer for shared, lifecycle or + security changes. Supply requirements, criteria, boundaries, rules, repository + and baseline only. Resolve material findings; defer documented low-value work. + +Record exact revisions, image/package versions, commands, results and limits. +Keep keys in private operator files; never commit them or include them in logs or +Feishu. Failed or synthetic runs cannot be counted as real acceptance. Merge the +bounded PR after required checks/review, update its board child and reassess the +full board. Acceptance of one engine is not complete public protocol compatibility. + +## MiniMax Code application + +The upstream project is [MiniMax-AI/minimax-code](https://github.com/MiniMax-AI/minimax-code). +The repository already contains an ACP stdio adapter under `agent/mcode`, including +native session creation/loading, events and human interactions. Its existing +product integration is not Agents API qualification: on the #701 baseline the +service catalog contains Codex and Claude only. + +The implementation reuses that adapter with an explicit native 0.4.12 opt-in for +`environment:none` text execution. It adds native active-input receipts and +cancellation settlement, a pure service profile and verified registration. No +public handler, store schema or scheduler engine branch is needed. Existing +product behavior and accepted Codex/Claude features remain protected. + +See [the MiniMax Code deployment guide](../../services/agents-api/deploy/mcode/README.md) +for setup, real-provider acceptance and limits. Hosted workspace, Files/Artifacts, +public functions and MCP are not qualified by the text profile. Native differences +remain separately tracked work; they do not require feature equality for onboarding. diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index bdfb1774..6f7b5335 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -8,6 +8,9 @@ means implementing its observable semantics. Harnesses do not need identical feature sets. Optional native limitations are separate capability work and do not block completion of otherwise qualified onboarding. +For implementation steps and interface obligations, see +[Add a native harness](harness-onboarding.md). + ## Integration surface 1. Implement the existing daemon `agent.Factory`/`Session` and, for prepared diff --git a/services/agents-api/deploy/mcode/README.md b/services/agents-api/deploy/mcode/README.md new file mode 100644 index 00000000..e0200c3b --- /dev/null +++ b/services/agents-api/deploy/mcode/README.md @@ -0,0 +1,122 @@ +# MiniMax Code text Runtime + +This profile targets `environment:none` through the same Core/daemon execution +contract as the other engines. It has no public workspace, Files, Artifacts, MCP, +function calls, image input or native Subagent execution. Those are separate +qualification tasks, not requirements to match Codex or Claude. + +## Pinned prerequisite + +Use the official [`@minimax-ai/code`](https://github.com/MiniMax-AI/minimax-code) +package version **0.4.12**, with Node.js 22.22.x and its native SQLite dependency. +The inspected upstream source is `33b259bbbeb1c16433390869938191d09bdb0680`. +Install outside the checkout, under a private operator directory in `~/.parsar/`. +Check the native install succeeds and `mcode --version` reports exactly 0.4.12. +The ordinary Parsar product image retains its existing package pin; this profile +uses an explicitly selected native executable on a trusted execution host. + +Set `PARSAR_MCODE_BIN` to that absolute executable and `PARSAR_MCODE_AGENTS_API=1` +for the daemon. The opt-in only advertises the profile for the qualified version. +Use the existing authenticated daemon connection and operator device enrollment; +this is not a new public enrollment API or official `self_hosted` implementation. +Set `AGENTS_API_ENGINE=mcode` in the independent Core deployment. Existing Sessions +retain their engine. Do not expose a new public harness selector. + +## Provider configuration + +Keep `AGENTS_API_EXECUTION_OPTIONS_FILE` private (mode 0600). Supply the existing +`mcode_provider` native custom-provider shape, for example: + +```json +{ + "mcode_provider": { + "name": "Parsar", + "kind": "custom", + "enabled": true, + "npm": "@ai-sdk/anthropic", + "options": { + "baseURL": "https://api.moonshot.cn/anthropic", + "apiKey": "" + }, + "models": { + "kimi-k3": { + "name": "Kimi K3", + "tool_call": true, + "limit": {"context": 64000, "output": 4096} + } + } + } +} +``` + +The public Agent model must appear in this managed provider's native model list. +The adapter selects it explicitly; it does not use a native account fallback. +Use the real provider endpoint for the selected credential. Test proxies may +forward requests unchanged and capture tool names/status, but must not synthesize +model responses or log keys. + +## Execution boundaries + +Each API Session uses a separate native state directory and cwd. The execution +child inherits only process and model-network essentials; it does not inherit +Core/daemon tokens or arbitrary Node startup configuration. The adapter owns its +native config, instructions and home and disables external skills, delegated work, +web search, file/shell tools, browser tools, mcode-tools and native goals. + +The native model may still see `skill`, `task_query`, `task_output` and `task_stop`. +The first loads an exact registered skill name and cannot execute a script; the +configured builtin/external skill catalog is empty. Task utilities cannot create +work and enforce native Session ownership. Auxiliary native title requests are +internal bookkeeping. Do not equate these with public function or workspace tools. + +ACP `mcode/session/steer` acknowledges acceptance for the active native Turn, +separately from the transport write. It is not a promise that the model consumed +that input before cancellation. Unknown outcomes are never automatically replayed. +Cancellation waits for process-group exit and output settlement. Public slash +text remains a model message instead of invoking native ACP operator commands. + +Cold continuation requires the exact persisted native Session ID and matching +private cwd. Missing/foreign history fails; recovery by guessing an ID from native +session listings is not qualified. Public usage breakdown is unavailable because +native ACP context occupancy and cumulative cost are not per-Turn usage. + +## Acceptance + +The opt-in `TestNativeMCodePublicExecution` uses the fixed official Python SDK, +raw HTTP, actual daemon/gateway/Worker and a dedicated PostgreSQL test database. +Provide private `PARSAR_MCODE_REAL_OPTIONS` (the provider object above plus the +`model` string), `PARSAR_MCODE_BIN`, `PARSAR_NATIVE_DAEMON_BIN`, +`PARSAR_NATIVE_PROOF_DIR`, `PARSAR_OFFICIAL_SDK_PYTHON` and +`PARSAR_AGENTS_API_TEST_DATABASE_URL`, then run: + +```sh +go test ./services/agents-api/internal/store \ + -run '^TestNativeMCodePublicExecution$' -count=1 -v -timeout=15m +``` + +It verifies real text execution, same-Turn steering and durable input receipt, +daemon cold restart with the same native ID, ordinary slash text, foreign-tenant +rejection, cancellation and subsequent continuation. Run independently with real +Kimi and MiniMax options. This fixture creates only operator device credentials +privately; all tested Sessions and inputs enter through public HTTP. + +`TestNativeMCodeHistoryIsolation` additionally takes +`PARSAR_MCODE_FOREIGN_NATIVE_ID` from a successful public run and verifies rejection +in another private native home, plus rejection of a nonexistent history ID. Run it +in the daemon's `internal/agent/mcode` package with the same private native/provider +options. It must fail before model input, without a replacement native Session. + +On 2026-09-19, the public fixture passed independently with real Kimi K3 and +MiniMax M2.7 APIs. Both exercised execution, native steering receipts, daemon +restart, history continuation, tenant rejection and cancel/continue. Native +missing/foreign history rejection passed separately. Credential scans found the +provider key only in each private mode-0600 native config, not in logs, public +results or native history. The ordinary product's pinned 0.3.11 ACP regression +also passed new/resume, model/instruction refresh, Skill discovery and MCP using +its existing synthetic provider fixture. + +This acceptance uses an in-process Core HTTP server and a separate real daemon; +it is not a standalone Core deployment certification. Hosted Docker isolation, +Files/Artifacts and complete official protocol compatibility remain unqualified. +Before release, record `make check`, race checks and independent review alongside +these native results in the board task. diff --git a/services/agents-api/internal/engine/mcode.go b/services/agents-api/internal/engine/mcode.go new file mode 100644 index 00000000..38e98e80 --- /dev/null +++ b/services/agents-api/internal/engine/mcode.go @@ -0,0 +1,23 @@ +package engine + +import ( + "errors" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func mcodeProfile() Profile { + return Profile{Placements: []string{"none"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { + if e == nil || e.Type != "none" || daemon || strings.TrimSpace(a.Model) == "" || a.MultiAgent.Enabled || a.MultiAgent.MaxConcurrentSubagents != nil || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { + return ErrInvalidInput + } + return nil + }, ValidateTools: func(_ *v1.Environment, _ bool, functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { + if len(functions) > 0 || len(mcp) > 0 { + return errors.New("The configured engine supports text execution without public tools.") + } + return nil + }} +} diff --git a/services/agents-api/internal/engine/profile.go b/services/agents-api/internal/engine/profile.go index 4a6317bd..a8549a99 100644 --- a/services/agents-api/internal/engine/profile.go +++ b/services/agents-api/internal/engine/profile.go @@ -50,4 +50,5 @@ func (c Catalog) Lookup(kind string) (Profile, bool) { var qualified = NewCatalog(map[string]Profile{ "codex": codexProfile(), "claude_sdk": claudeProfile(), + "mcode": mcodeProfile(), }) diff --git a/services/agents-api/internal/execution/mcode_profile_test.go b/services/agents-api/internal/execution/mcode_profile_test.go new file mode 100644 index 00000000..7967aecd --- /dev/null +++ b/services/agents-api/internal/execution/mcode_profile_test.go @@ -0,0 +1,25 @@ +package execution + +import "testing" + +func TestMCodeOperationQualification(t *testing.T) { + p := Policy{} + for _, tc := range []struct { + name, body string + valid bool + }{ + {"text", `{"agent":{"model":"real-model"},"environment":{"type":"none"}}`, true}, + {"hosted", `{"agent":{"model":"real-model"},"environment":{"type":"openai_hosted"}}`, false}, + {"verbosity", `{"agent":{"model":"real-model","text":{"verbosity":"high"}},"environment":{"type":"none"}}`, false}, + {"subagents", `{"agent":{"model":"real-model","multi_agent":{"enabled":true}},"environment":{"type":"none"}}`, false}, + {"functions", `{"agent":{"model":"real-model","tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]},"environment":{"type":"none"}}`, false}, + {"mcp", `{"agent":{"model":"real-model","tools":[{"type":"mcp","server_label":"x","server_url":"https://example.invalid/mcp"}]},"environment":{"type":"none"}}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := p.ValidateSessionConfiguration("mcode", []byte(tc.body)) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v err=%v", tc.valid, err) + } + }) + } +} diff --git a/services/agents-api/internal/store/mcode_public_native_test.go b/services/agents-api/internal/store/mcode_public_native_test.go new file mode 100644 index 00000000..80fbabc4 --- /dev/null +++ b/services/agents-api/internal/store/mcode_public_native_test.go @@ -0,0 +1,202 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// This opt-in fixture never supplies model responses. The provider options must +// name a real API; private operator files are deliberately outside the repository. +func TestNativeMCodePublicExecution(t *testing.T) { + python, binary, root, optionsFile := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON"), os.Getenv("PARSAR_NATIVE_DAEMON_BIN"), os.Getenv("PARSAR_NATIVE_PROOF_DIR"), os.Getenv("PARSAR_MCODE_REAL_OPTIONS") + if python == "" || binary == "" || root == "" || optionsFile == "" { + t.Skip("native daemon, fixed SDK, private real-model options and proof directory required") + } + raw, err := os.ReadFile(optionsFile) + if err != nil { + t.Fatal(err) + } + var options map[string]any + if json.Unmarshal(raw, &options) != nil { + t.Fatal("invalid private options") + } + model, _ := options["model"].(string) + if model == "" { + t.Fatal("real model required") + } + h := newDispatchHarness(t) + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { return options, nil } + home, err := os.MkdirTemp(root, "mcode-public-") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 12*time.Minute) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + stopped := make(chan error, 1) + go func() { stopped <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-stopped: + case <-time.After(20 * time.Second): + t.Error("worker did not stop") + } + }() + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant}, + {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(h.s, auth, "mcode", api.WithExecution(worker)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + stop := startMCodeDaemon(t, h, home, binary) + defer func() { stop() }() + evidence := filepath.Join(home, "public.json") + run := func(stage string) { + command := exec.CommandContext(ctx, python, "../../tests/official_mcode_native.py", server.URL, token, foreign, model, stage, evidence) + if log, err := command.CombinedOutput(); err != nil { + data, _ := os.ReadFile(evidence) + var identity struct { + Session string `json:"session"` + } + _ = json.Unmarshal(data, &identity) + if page, e := h.s.ListTurns(ctx, h.tenant, identity.Session, "", 100, true); e == nil { + diagnostic, _ := json.Marshal(page) + text := string(diagnostic) + if provider, ok := options["mcode_provider"].(map[string]any); ok { + if opts, ok := provider["options"].(map[string]any); ok { + if key, ok := opts["apiKey"].(string); ok && key != "" { + text = strings.ReplaceAll(text, key, "[REDACTED]") + } + } + } + _ = os.WriteFile(filepath.Join(home, "failed-turns.json"), []byte(text), 0600) + } + t.Fatalf("public mcode %s failed: %v %s; evidence %s", stage, err, log, home) + } + } + run("initial") + data, err := os.ReadFile(evidence) + if err != nil { + t.Fatal(err) + } + var proof struct { + Session string `json:"session"` + FirstTurn string `json:"first_turn"` + } + if json.Unmarshal(data, &proof) != nil { + t.Fatal("invalid evidence") + } + turn, err := h.s.GetTurn(ctx, h.tenant, proof.Session, proof.FirstTurn) + if err != nil { + t.Fatal(err) + } + inputs, err := h.s.ListTurnInputs(ctx, h.tenant, proof.Session, proof.FirstTurn, 0, 100) + if err != nil || len(inputs) != 2 { + t.Fatal("steering input not in same turn", err) + } + var outcome execution.Result + if json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.AppliedThrough != inputs[1].Sequence { + t.Fatal("native applied receipt missing") + } + before, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, proof.Session) + if err != nil || before.NativeSessionID == "" { + t.Fatal("native binding missing", err) + } + stop() + stop = startMCodeDaemon(t, h, home, binary) + run("resume") + after, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, proof.Session) + if err != nil || before.NativeSessionID != after.NativeSessionID { + t.Fatal("native history changed", err) + } + if err := os.WriteFile(filepath.Join(home, "native-session-id"), []byte(after.NativeSessionID), 0600); err != nil { + t.Fatal(err) + } + t.Logf("Real mcode common-contract acceptance passed: %s", home) +} + +func startMCodeDaemon(t *testing.T, h *dispatchHarness, home, binary string) func() { + t.Helper() + if h.conn != nil { + _ = h.conn.Close() + } + profile := filepath.Join(home, "parsar-daemon", "execution") + if err := os.MkdirAll(profile, 0700); err != nil { + t.Fatal(err) + } + auth, _ := json.Marshal(map[string]string{"server_url": h.url + "/api/v1", "runtime_id": h.device.ID, "runner_credential": h.credential, "device_name": "mcode native proof"}) + if err := os.WriteFile(filepath.Join(profile, "auth.json"), auth, 0600); err != nil { + t.Fatal(err) + } + log, err := os.OpenFile(filepath.Join(home, "daemon.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + old, _ := h.registry.LookupDevice(h.device.ID) + cmd := exec.Command(binary, "connect", "--profile", "execution") + cmd.Env = append(os.Environ(), "PARSAR_HOME="+home, "PARSAR_MCODE_AGENTS_API=1") + cmd.Stdout, cmd.Stderr = log, log + if err = cmd.Start(); err != nil { + log.Close() + t.Fatal(err) + } + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + stop := func() { + select { + case <-done: + return + default: + } + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-done: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-done + } + _ = log.Close() + } + t.Cleanup(stop) + deadline := time.Now().Add(45 * time.Second) + for time.Now().Before(deadline) { + if peer, err := h.registry.LookupDevice(h.device.ID); err == nil && peer != old { + if info, found, known := peer.AgentKindStatus("mcode"); found && known && info.Available && info.Capabilities.EnvironmentNone { + return stop + } + } + select { + case <-done: + t.Fatalf("daemon exited; evidence %s", home) + default: + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("native mcode daemon not ready; evidence %s", home) + return stop +} diff --git a/services/agents-api/tests/official_mcode_native.py b/services/agents-api/tests/official_mcode_native.py new file mode 100644 index 00000000..25b4ba92 --- /dev/null +++ b/services/agents-api/tests/official_mcode_native.py @@ -0,0 +1,95 @@ +"""Opt-in real-model MiniMax Code acceptance through the pinned public client.""" +import importlib.metadata +import json +import sys +import time +import uuid +from pathlib import Path + +import httpx2 +from openai import OpenAI + + +def main(): + base, token, foreign, model, stage, output = sys.argv[1:] + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + dist = importlib.metadata.distribution("openai") + assert dist.version == pin["sdk_version"] + assert json.loads(dist.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] + http = httpx2.Client(trust_env=False, timeout=300) + client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, + _strict_response_validation=True, http_client=http) + sessions = client.beta.agents.sessions + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + record = {} if stage == "initial" else json.loads(Path(output).read_text()) + + def message(text): + return {"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": text}]}]} + + def submit(sid, text, key): + sessions.events.create(sid, events=[message(text)], idempotency_key=key) + + def execute(sid, text, steer=False, cancel=False): + types, submitted = [], False + with sessions.events.stream(sid, timeout=300) as stream: + submit(sid, text, str(uuid.uuid4())) + for event in stream: + types.append(event.type) + assert event.type != "agent.session.failed", event + if event.type == "agent.session.turn.output_text.delta" and not submitted: + submitted = True + if steer: + submit(sid, "Stop the list now and reply MCODE-STEERED.", "steer") + if cancel: + sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}]) + if event.type == "agent.session.idle": + break + end = "agent.session.turn.cancelled" if cancel else "agent.session.turn.completed" + assert end in types, types + assert types.index("agent.session.turn.created") < types.index(end) < len(types) - 1 + if steer or cancel: + assert submitted, "No native output to trigger the operation" + return types + + def answer(sid): + items = sessions.items.list(sid, order="asc", limit=100).data + answers = [i for i in items if i.type == "message" and i.role == "assistant"] + return "\n".join(c.text for c in answers[-1].content if c.type == "output_text") + + try: + if stage == "initial": + marker = "MCODE-MEMORY-" + uuid.uuid4().hex[:12] + session = sessions.create(agent={"model": model, "instructions": "Follow user instructions. Remember supplied markers. Do not use tools."}, environment={"type": "none"}) + record = {"session": session.id, "marker": marker, "model": model, "checks": []} + Path(output).write_text(json.dumps(record, indent=2)) + for agent_patch, environment in [({"tools": [{"type": "function", "name": "f", "parameters": {"type": "object"}}]}, {"type": "none"}), ({"text": {"verbosity": "high"}}, {"type": "none"}), ({}, {"type": "openai_hosted"})]: + r = http.post(base + "/v1/agents/sessions", headers=headers, json={"agent": {"model": model, **agent_patch}, "environment": environment}) + assert r.status_code == 400, r.status_code + record["initial_events"] = execute(session.id, "Remember " + marker + ". Write 120 numbered lines explaining addition, one sentence per line. Start immediately.", steer=True) + turns = sessions.turns.list(session.id, order="asc", limit=100).data + assert len(turns) == 1 and turns[0].status == "completed", [(t.id, t.status) for t in turns] + record["first_turn"] = turns[0].id + record["checks"] += ["real_native_execution", "active_steering_same_turn", "unsupported_operations_rejected"] + else: + sid = record["session"] + execute(sid, "Reply with the MCODE-MEMORY marker I gave you earlier, and nothing else.") + assert record["marker"] in answer(sid), "Cold native history was not continued" + foreign_headers = {**headers, "Authorization": "Bearer " + foreign} + for path in ["", "/items", "/turns"]: + assert http.get(base + "/v1/agents/sessions/" + sid + path, headers=foreign_headers).status_code == 404 + # The input must remain ordinary text instead of triggering ACP /model. + execute(sid, "/model") + assert sessions.turns.list(sid, order="asc", limit=100).data[-1].status == "completed" + record["cancel_events"] = execute(sid, "Write 500 numbered lines, each explaining a different arithmetic fact. Start immediately.", cancel=True) + execute(sid, "Reply with the original MCODE-MEMORY marker only.") + assert record["marker"] in answer(sid) + record["checks"] += ["cold_daemon_history_continuation", "foreign_tenant_rejected", "slash_text_execution", "cancel_and_continue"] + record["passed"] = True + Path(output).write_text(json.dumps(record, indent=2)) + finally: + client.close() + + +if __name__ == "__main__": + main() From 452e208664566cc4235a2ce02d38eaad9512b915 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 19 Sep 2026 13:25:15 +0800 Subject: [PATCH 2/3] Use the current MiniMax Code package and native startup permissions --- CONTRIBUTING.md | 4 +-- .../internal/agent/mcode/execution.go | 4 +-- .../internal/agent/mcode/options.go | 21 ++++++++------- .../internal/agent/mcode/options_test.go | 4 +++ .../internal/agent/mcode/session.go | 10 ++----- .../internal/agent/mcode/version.go | 9 ++++++- .../internal/agent/mcode/version_test.go | 26 +++++++++++++++++++ .../internal/cli/connect_test.go | 4 +-- apps/parsar-daemon/internal/cli/mcode.go | 2 +- infra/sandbox/Dockerfile | 2 +- infra/sandbox/e2b.Dockerfile | 2 +- infra/sandbox/scripts/install-agents.sh | 4 +-- services/agents-api/deploy/mcode/README.md | 7 +++-- 13 files changed, 64 insertions(+), 35 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/mcode/version_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be2f332a..20f1aa5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1975,8 +1975,8 @@ for implementation and registration steps. MiniMax Code's opt-in Agents API profile qualifies native ACP 0.4.12 for `environment:none` text execution. It reuses the shared lifecycle without public -workspace, functions, MCP or native Subagents. Keep the ordinary product package -pin unchanged. Native configuration disables file/shell authority and external +workspace, functions, MCP or native Subagents. Native configuration disables +file/shell authority and external capability discovery; the child receives a private Session home and a restricted environment. Active-input application requires a native ACP receipt, cancellation settles the process and output, and continuation requires the exact owned native diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go index b1f5ea2d..5b0c829a 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution.go @@ -7,11 +7,9 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -const ExecutionVersion = "0.4.12" - // SupportsExecution is an operator opt-in, separate from ordinary product availability. func SupportsExecution(version string) bool { - return os.Getenv("PARSAR_MCODE_AGENTS_API") == "1" && version == ExecutionVersion + return os.Getenv("PARSAR_MCODE_AGENTS_API") == "1" && version == SupportedVersion } func validateExecutionRequest(req proto.PromptRequestPayload) error { diff --git a/apps/parsar-daemon/internal/agent/mcode/options.go b/apps/parsar-daemon/internal/agent/mcode/options.go index 80d5c2af..1d420329 100644 --- a/apps/parsar-daemon/internal/agent/mcode/options.go +++ b/apps/parsar-daemon/internal/agent/mcode/options.go @@ -16,9 +16,9 @@ import ( ) type launchOptions struct { - Dir, DataDir, Model, Mode string - Env []string - MCP []map[string]any + Dir, DataDir, Model string + Env []string + MCP []map[string]any } func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { @@ -79,6 +79,14 @@ func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launch if req.StrictResume { configureTextExecution(config) } + mode := optionString(opts, "mode") + if mode == "" { + mode = "auto" + } + if mode != "auto" && mode != "default" && mode != "bypassPermissions" { + return result, fmt.Errorf("mcode: unsupported permission mode") + } + config["permissionMode"] = mode data, err := json.Marshal(config) if err != nil { return result, err @@ -108,13 +116,6 @@ func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launch if req.StrictResume { result.Env = append(result.Env, "HOME="+result.DataDir, "USERPROFILE="+result.DataDir) } - result.Mode = optionString(opts, "mode") - if result.Mode == "" { - result.Mode = "auto" - } - if result.Mode != "auto" && result.Mode != "default" && result.Mode != "bypassPermissions" { - return result, fmt.Errorf("mcode: unsupported permission mode") - } result.MCP, err = mcpServers(opts["mcp_servers"]) return result, err } diff --git a/apps/parsar-daemon/internal/agent/mcode/options_test.go b/apps/parsar-daemon/internal/agent/mcode/options_test.go index ab32c7ce..a2508a05 100644 --- a/apps/parsar-daemon/internal/agent/mcode/options_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/options_test.go @@ -25,6 +25,7 @@ func TestOptionsRefreshManagedState(t *testing.T) { t.Fatal("state override did not win") } req.AgentOptions["system_prompt"] = "" + req.AgentOptions["mode"] = "default" req.AgentSessionID = "native-1" refreshed, err := prepareOptions(context.Background(), req) if err != nil { @@ -45,6 +46,9 @@ func TestOptionsRefreshManagedState(t *testing.T) { if json.Unmarshal(data, &cfg) != nil { t.Fatal("invalid config") } + if cfg["permissionMode"] != "default" { + t.Fatal("requested native permission mode was not refreshed") + } if cfg["skills"].(map[string]any)["external"].(map[string]any)["enabled"] != false { t.Fatal("external discovery enabled") } diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go index cd43fcef..59ffdf9a 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session.go +++ b/apps/parsar-daemon/internal/agent/mcode/session.go @@ -178,14 +178,8 @@ func (s *Session) execute() error { if err != nil { return err } - settings := []struct{ key, value string }{{"model", model}} - if !s.req.StrictResume { - settings = append(settings, struct{ key, value string }{"permissionMode", s.opts.Mode}) - } - for _, config := range settings { - if err := s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": config.key, "value": config.value}, nil, false); err != nil { - return err - } + if err := s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "model", "value": model}, nil, false); err != nil { + return err } s.active = true var result struct { diff --git a/apps/parsar-daemon/internal/agent/mcode/version.go b/apps/parsar-daemon/internal/agent/mcode/version.go index 79c9095b..a23600bf 100644 --- a/apps/parsar-daemon/internal/agent/mcode/version.go +++ b/apps/parsar-daemon/internal/agent/mcode/version.go @@ -3,6 +3,7 @@ package mcode import ( "context" "errors" + "fmt" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/binpath" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/versionprobe" @@ -10,8 +11,14 @@ import ( var ErrCLINotFound = errors.New("mcode CLI not found") +const SupportedVersion = "0.4.12" + func defaultBinary() string { return binpath.MCode() } func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { - return versionprobe.Check(ctx, binary, versionprobe.Config{Name: "mcode", DefaultBinary: defaultBinary(), MissingError: ErrCLINotFound, TrimBinary: true}) + version, err := versionprobe.Check(ctx, binary, versionprobe.Config{Name: "mcode", DefaultBinary: defaultBinary(), MissingError: ErrCLINotFound, TrimBinary: true}) + if err == nil && version != SupportedVersion { + err = fmt.Errorf("mcode: unsupported version %s; install %s", version, SupportedVersion) + } + return version, err } diff --git a/apps/parsar-daemon/internal/agent/mcode/version_test.go b/apps/parsar-daemon/internal/agent/mcode/version_test.go new file mode 100644 index 00000000..f85957b8 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/version_test.go @@ -0,0 +1,26 @@ +package mcode + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestOnlyQualifiedLatestCLIIsAvailable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX CLI fixture") + } + for _, version := range []string{SupportedVersion, "0.3.11", "0.4.13"} { + t.Run(version, func(t *testing.T) { + binary := filepath.Join(t.TempDir(), "mcode") + if err := os.WriteFile(binary, []byte("#!/bin/sh\necho "+version+"\n"), 0700); err != nil { + t.Fatal(err) + } + got, err := CheckCLIAvailable(t.Context(), binary) + if got != version || (err == nil) != (version == SupportedVersion) { + t.Fatalf("version %q, error %v", got, err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_test.go b/apps/parsar-daemon/internal/cli/connect_test.go index d6c33f5f..fbfc7fd2 100644 --- a/apps/parsar-daemon/internal/cli/connect_test.go +++ b/apps/parsar-daemon/internal/cli/connect_test.go @@ -148,7 +148,7 @@ func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { stdout, stderr := &strings.Builder{}, &strings.Builder{} rc := &runContext{stdout: stdout, stderr: stderr} got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ - MCode: func(context.Context, string) (string, error) { return "0.3.11", nil }, + MCode: func(context.Context, string) (string, error) { return "0.4.12", nil }, ClaudeCode: func(context.Context, string) (string, error) { return "claude 2.0.0", nil }, @@ -197,7 +197,7 @@ func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { func TestRegisterAgentKindsPreservesDescriptors(t *testing.T) { reg := agent.NewRegistry() registerAgentKinds(reg, agentCLIDiscovery{ - MCode: proto.SupportedAgentKind{Kind: "mcode", Available: true, Version: "0.3.11", Capabilities: proto.AgentKindCapabilities{Streaming: true, Permissions: true, Resume: true}}, + MCode: proto.SupportedAgentKind{Kind: "mcode", Available: true, Version: "0.4.12", Capabilities: proto.AgentKindCapabilities{Streaming: true, Permissions: true, Resume: true}}, ClaudeCode: proto.SupportedAgentKind{ Kind: "claude_code", Available: true, diff --git a/apps/parsar-daemon/internal/cli/mcode.go b/apps/parsar-daemon/internal/cli/mcode.go index 48e05d6e..13a0b7bb 100644 --- a/apps/parsar-daemon/internal/cli/mcode.go +++ b/apps/parsar-daemon/internal/cli/mcode.go @@ -17,7 +17,7 @@ func discoverMCode(rc *runContext, check func(context.Context, string) (string, defer cancel() version, err := check(ctx, "") if err != nil { - fmt.Fprintf(rc.stderr, "parsar-daemon: mcode unavailable: %v\n Install: npm install -g @minimax-ai/code@0.3.11\n", err) + fmt.Fprintf(rc.stderr, "parsar-daemon: mcode unavailable: %v\n Install: npm install -g @minimax-ai/code@0.4.12\n", err) return result } result.Available, result.Version = true, version diff --git a/infra/sandbox/Dockerfile b/infra/sandbox/Dockerfile index 71f9a447..c6a9eaff 100644 --- a/infra/sandbox/Dockerfile +++ b/infra/sandbox/Dockerfile @@ -100,7 +100,7 @@ RUN apt-get update -y \ ARG CLAUDE_CODE_VERSION="" ARG CODEX_VERSION="0.141.0" ARG PI_VERSION="0.80.6" -ARG MCODE_VERSION=0.3.11 +ARG MCODE_VERSION=0.4.12 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" MCODE_VERSION="$MCODE_VERSION" \ /tmp/install-agents.sh "$TARGETARCH" \ diff --git a/infra/sandbox/e2b.Dockerfile b/infra/sandbox/e2b.Dockerfile index 9d20076c..71644e67 100644 --- a/infra/sandbox/e2b.Dockerfile +++ b/infra/sandbox/e2b.Dockerfile @@ -70,7 +70,7 @@ RUN apt-get update -y \ # which is the behaviour we want here anyway. ARG CODEX_VERSION=0.141.0 ARG PI_VERSION=0.80.6 -ARG MCODE_VERSION=0.3.11 +ARG MCODE_VERSION=0.4.12 # Staged under /opt, NOT /tmp: e2b's builder does not persist /tmp across # layers, so `COPY ... /tmp/x` followed by `RUN /tmp/x` in the next layer # fails with "No such file or directory". The main Dockerfile can use /tmp diff --git a/infra/sandbox/scripts/install-agents.sh b/infra/sandbox/scripts/install-agents.sh index 3215865c..baa587ea 100755 --- a/infra/sandbox/scripts/install-agents.sh +++ b/infra/sandbox/scripts/install-agents.sh @@ -10,7 +10,7 @@ # CLAUDE_CODE_VERSION default: latest # CODEX_VERSION default: 0.141.0 # PI_VERSION default: 0.80.6 -# MCODE_VERSION default: 0.3.11 +# MCODE_VERSION default: 0.4.12 # # 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 @@ -22,7 +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}" -MCODE_VERSION="${MCODE_VERSION:-0.3.11}" +MCODE_VERSION="${MCODE_VERSION:-0.4.12}" case "$TARGETARCH" in amd64) CLAUDE_ARCH=linux-x64 CODEX_ARCH=x86_64-unknown-linux-musl ;; diff --git a/services/agents-api/deploy/mcode/README.md b/services/agents-api/deploy/mcode/README.md index e0200c3b..cd7c1069 100644 --- a/services/agents-api/deploy/mcode/README.md +++ b/services/agents-api/deploy/mcode/README.md @@ -12,8 +12,7 @@ package version **0.4.12**, with Node.js 22.22.x and its native SQLite dependenc The inspected upstream source is `33b259bbbeb1c16433390869938191d09bdb0680`. Install outside the checkout, under a private operator directory in `~/.parsar/`. Check the native install succeeds and `mcode --version` reports exactly 0.4.12. -The ordinary Parsar product image retains its existing package pin; this profile -uses an explicitly selected native executable on a trusted execution host. +This profile runs on a trusted execution host. Set `PARSAR_MCODE_BIN` to that absolute executable and `PARSAR_MCODE_AGENTS_API=1` for the daemon. The opt-in only advertises the profile for the qualified version. @@ -111,8 +110,8 @@ MiniMax M2.7 APIs. Both exercised execution, native steering receipts, daemon restart, history continuation, tenant rejection and cancel/continue. Native missing/foreign history rejection passed separately. Credential scans found the provider key only in each private mode-0600 native config, not in logs, public -results or native history. The ordinary product's pinned 0.3.11 ACP regression -also passed new/resume, model/instruction refresh, Skill discovery and MCP using +results or native history. Product ACP regression uses the same 0.4.12 package +and covers new/resume, model/instruction refresh, Skill discovery and MCP using its existing synthetic provider fixture. This acceptance uses an in-process Core HTTP server and a separate real daemon; From b4fae76d33c745b39daf2bad0a27ac529222f45f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 19 Sep 2026 13:31:36 +0800 Subject: [PATCH 3/3] Use a bounded reasoning prompt for native cancellation acceptance --- services/agents-api/tests/official_mcode_native.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/agents-api/tests/official_mcode_native.py b/services/agents-api/tests/official_mcode_native.py index 25b4ba92..dcacd60b 100644 --- a/services/agents-api/tests/official_mcode_native.py +++ b/services/agents-api/tests/official_mcode_native.py @@ -81,7 +81,7 @@ def answer(sid): # The input must remain ordinary text instead of triggering ACP /model. execute(sid, "/model") assert sessions.turns.list(sid, order="asc", limit=100).data[-1].status == "completed" - record["cancel_events"] = execute(sid, "Write 500 numbered lines, each explaining a different arithmetic fact. Start immediately.", cancel=True) + record["cancel_events"] = execute(sid, "Print the integers from 1 to 10000, one per line. Start with 1 immediately; no explanation or planning.", cancel=True) execute(sid, "Reply with the original MCODE-MEMORY marker only.") assert record["marker"] in answer(sid) record["checks"] += ["cold_daemon_history_continuation", "foreign_tenant_rejected", "slash_text_execution", "cancel_and_continue"]