feat(agents): add Hermes ACP agent integration (#2089)
This commit is contained in:
committed by
GitHub
parent
abf21f52a6
commit
26db9fc260
@@ -31,7 +31,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, AWS Kiro, Qoder, Trae, Oh My Pi, Grok
|
||||
- **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, AWS Kiro, Qoder, Trae, Oh My Pi, Grok, Hermes
|
||||
- **Parallel task execution** – start and manage multiple tasks from different sources simultaneously, boosting productivity with AI agents
|
||||
- **Integrated workspace** - Built-in terminal, code editor with LSP, git changes panel, embedded vscode and chat in one IDE-like view
|
||||
- **Kanban task management** - Drag-and-drop boards, columns, and workflow automation
|
||||
@@ -92,6 +92,7 @@ Connect Kandev to the tools your team already uses — pull issues into the kanb
|
||||
| **Trae** | `traecli acp serve` *(install Trae IDE CLI)* |
|
||||
| **Oh My Pi** | `omp acp` *(install `bun install -g @oh-my-pi/pi-coding-agent`)* |
|
||||
| **Grok** | `grok --no-auto-update agent stdio` *(install `npm install -g @xai-official/grok`)* |
|
||||
| **Hermes** | `hermes acp` *(install with the official Hermes installer)* |
|
||||
|
||||
> All agents communicate via [ACP](https://agentclientprotocol.com) (Agent Client Protocol). Some agents support ACP natively, while others use ACP adapter packages that bridge their native protocols. **CLI Passthrough mode** is available when an integration provides a passthrough command. If your agent isn't supported yet, open an issue or submit a PR with the integration. See [Adding a New Agent CLI](docs/add-agent-cli.md) for a step-by-step guide.
|
||||
|
||||
@@ -259,7 +260,7 @@ There are a few similar tools in this space, and new ones appearing everyday. He
|
||||
|
||||
- **Server-first architecture** - The core app runs as a server you can access from any device, including your phone.
|
||||
- **Remote runtimes** - Run agents on remote servers via SSH, Docker hosts, and cloud environments, not just your local machine.
|
||||
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, Oh My Pi, and Grok side by side. Not locked to one vendor.
|
||||
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Devin, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, Oh My Pi, Grok, and Hermes side by side. Not locked to one vendor.
|
||||
- **CLI passthrough and chat** - Interact with agents through structured chat messages or, where supported, drop into raw CLI mode for full agent TUI capabilities.
|
||||
- **Open source and self-hostable** - No vendor lock-in, no telemetry, runs on your infrastructure.
|
||||
|
||||
|
||||
@@ -37,6 +37,21 @@ func StripEnvFor(ia InferenceAgent) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RuntimeEnvFor returns a defensive copy of the agent runtime environment for
|
||||
// one-shot probe and inference subprocesses.
|
||||
func RuntimeEnvFor(ia InferenceAgent) map[string]string {
|
||||
if a, ok := ia.(Agent); ok {
|
||||
if rt := a.Runtime(); rt != nil && len(rt.Env) > 0 {
|
||||
env := make(map[string]string, len(rt.Env))
|
||||
for key, value := range rt.Env {
|
||||
env[key] = value
|
||||
}
|
||||
return env
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeAgentctlAutoApprove(settings map[string]PermissionSetting) map[string]PermissionSetting {
|
||||
merged := make(map[string]PermissionSetting, len(settings)+1)
|
||||
for k, v := range settings {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//nolint:dupl // Native-binary ACP agents (Kiro, Qoder, Hermes, ...) follow the same minimal scaffold; differences are the binary name, argv, and auth surface.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/usage"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/hermes_light.svg
|
||||
var hermesLogoLight []byte
|
||||
|
||||
//go:embed logos/hermes_dark.svg
|
||||
var hermesLogoDark []byte
|
||||
|
||||
const hermesBin = "hermes"
|
||||
|
||||
var (
|
||||
_ Agent = (*HermesACP)(nil)
|
||||
_ PassthroughAgent = (*HermesACP)(nil)
|
||||
_ InferenceAgent = (*HermesACP)(nil)
|
||||
_ LoginAgent = (*HermesACP)(nil)
|
||||
)
|
||||
|
||||
// HermesACP implements Agent for Nous Research's Hermes Agent using native
|
||||
// ACP over stdin/stdout (`hermes acp`). Hermes is installed as a Python
|
||||
// package by the official installer, which bundles the `[acp]` extra, so the
|
||||
// launch command is the bare `hermes` binary.
|
||||
type HermesACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewHermesACP() *HermesACP {
|
||||
return &HermesACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand(hermesBin),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *HermesACP) ID() string { return "hermes-acp" }
|
||||
func (a *HermesACP) Name() string { return "Hermes ACP Agent" }
|
||||
func (a *HermesACP) DisplayName() string { return "Hermes" }
|
||||
func (a *HermesACP) Description() string {
|
||||
return "Nous Research Hermes coding agent using the ACP protocol via hermes acp."
|
||||
}
|
||||
func (a *HermesACP) Enabled() bool { return true }
|
||||
func (a *HermesACP) DisplayOrder() int { return 21 }
|
||||
|
||||
func (a *HermesACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return hermesLogoDark
|
||||
}
|
||||
return hermesLogoLight
|
||||
}
|
||||
|
||||
func (a *HermesACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
result, err := Detect(ctx, WithCommand(hermesBin))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *HermesACP) BuildCommand(_ CommandOptions) Command {
|
||||
return Cmd(hermesBin, "acp").Build()
|
||||
}
|
||||
|
||||
func (a *HermesACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd(hermesBin, "acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{
|
||||
// Hermes starts every globally configured MCP server from
|
||||
// ~/.hermes/config.yaml before its ACP loop begins. Kandev owns
|
||||
// MCP itself and passes session servers through ACP session/new,
|
||||
// so the global startup is redundant — and an unrelated slow
|
||||
// server would otherwise delay initialize. Only the global
|
||||
// discovery is skipped; session/new MCP servers still register.
|
||||
"HERMES_ACP_SKIP_CONFIGURED_MCP": "1",
|
||||
},
|
||||
ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour},
|
||||
Protocol: agent.ProtocolACP,
|
||||
UserSkillDir: ".hermes/skills",
|
||||
SessionConfig: SessionConfig{
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.hermes",
|
||||
SessionDirTarget: "/root/.hermes",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *HermesACP) RemoteAuth() *RemoteAuth {
|
||||
return &RemoteAuth{
|
||||
Methods: []RemoteAuthMethod{
|
||||
{
|
||||
Type: "files",
|
||||
Label: "Copy auth files",
|
||||
SourceFiles: map[string][]string{
|
||||
"darwin": {".hermes/.env", ".hermes/config.yaml"},
|
||||
"linux": {".hermes/.env", ".hermes/config.yaml"},
|
||||
},
|
||||
// Credentials + provider/model config only — sessions, skills,
|
||||
// checkpoints, and state.db are deliberately excluded.
|
||||
TargetRelDir: ".hermes",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// hermes model is the interactive provider/model setup wizard (add providers,
|
||||
// OAuth flows, API keys). Credentials land in ~/.hermes/.env + config.yaml.
|
||||
func (a *HermesACP) LoginCommand() *LoginCommand {
|
||||
return &LoginCommand{
|
||||
Cmd: []string{hermesBin, "model"},
|
||||
Description: "Configure provider credentials and select a model.",
|
||||
}
|
||||
}
|
||||
|
||||
func (a *HermesACP) InstallScript() string {
|
||||
return "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
|
||||
}
|
||||
|
||||
func (a *HermesACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *HermesACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand(hermesBin, "acp"),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *HermesACP) BillingType() usage.BillingType { return defaultBillingType() }
|
||||
@@ -0,0 +1,201 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/usage"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
func TestHermesACP_IDAndDisplay(t *testing.T) {
|
||||
a := NewHermesACP()
|
||||
if got := a.ID(); got != "hermes-acp" {
|
||||
t.Errorf("ID() = %q, want hermes-acp", got)
|
||||
}
|
||||
if got := a.DisplayName(); got != "Hermes" {
|
||||
t.Errorf("DisplayName() = %q, want Hermes", got)
|
||||
}
|
||||
if !a.Enabled() {
|
||||
t.Error("Enabled() = false, want true")
|
||||
}
|
||||
if got := a.DisplayOrder(); got != 21 {
|
||||
t.Errorf("DisplayOrder() = %d, want 21", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesACP_AllCommandSurfaces(t *testing.T) {
|
||||
a := NewHermesACP()
|
||||
want := []string{"hermes", "acp"}
|
||||
|
||||
assertArgvEqual(t, "BuildCommand", a.BuildCommand(CommandOptions{}).Args(), want)
|
||||
|
||||
rt := a.Runtime()
|
||||
if rt == nil {
|
||||
t.Fatal("Runtime() returned nil")
|
||||
}
|
||||
if rt.Protocol != agent.ProtocolACP {
|
||||
t.Errorf("Runtime.Protocol = %q, want ACP", rt.Protocol)
|
||||
}
|
||||
assertArgvEqual(t, "Runtime.Cmd", rt.Cmd.Args(), want)
|
||||
|
||||
ic := a.InferenceConfig()
|
||||
if ic == nil || !ic.Supported {
|
||||
t.Fatalf("InferenceConfig() = %+v, want Supported=true", ic)
|
||||
}
|
||||
assertArgvEqual(t, "InferenceConfig.Command", ic.Command.Args(), want)
|
||||
|
||||
pa, ok := any(a).(PassthroughAgent)
|
||||
if !ok {
|
||||
t.Fatal("HermesACP must implement PassthroughAgent")
|
||||
}
|
||||
assertArgvEqual(t, "PassthroughCmd", pa.PassthroughConfig().PassthroughCmd.Args(), []string{"hermes"})
|
||||
}
|
||||
|
||||
// TestHermesACP_SkipsGlobalMCPStartup pins the host-owned MCP marker. Kandev
|
||||
// passes session MCP servers through ACP session/new; without the marker
|
||||
// Hermes would ALSO start every globally configured server from
|
||||
// ~/.hermes/config.yaml before initialize, duplicating work and slowing the
|
||||
// handshake. The marker must stay exactly "1" — any other value keeps the
|
||||
// default behavior.
|
||||
func TestHermesACP_SkipsGlobalMCPStartup(t *testing.T) {
|
||||
rt := NewHermesACP().Runtime()
|
||||
if got := rt.Env["HERMES_ACP_SKIP_CONFIGURED_MCP"]; got != "1" {
|
||||
t.Errorf("HERMES_ACP_SKIP_CONFIGURED_MCP = %q, want exactly \"1\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesACP_InstallScript(t *testing.T) {
|
||||
got := NewHermesACP().InstallScript()
|
||||
for _, needle := range []string{
|
||||
"curl -fsSL https://hermes-agent.nousresearch.com/install.sh",
|
||||
"hermes",
|
||||
} {
|
||||
if !strings.Contains(got, needle) {
|
||||
t.Errorf("InstallScript missing %q: %q", needle, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "Install Hermes") {
|
||||
t.Errorf("InstallScript must be executable shell, got prose: %q", got)
|
||||
}
|
||||
if strings.HasPrefix(got, "npm install -g ") {
|
||||
t.Errorf("InstallScript should use the native Hermes installer, got npm script: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesACP_DetectionRequiresGlobalBinary(t *testing.T) {
|
||||
if _, err := exec.LookPath("hermes"); err == nil {
|
||||
t.Skip("detection binary \"hermes\" is on PATH; can't verify availability requirement")
|
||||
}
|
||||
result, err := NewHermesACP().IsInstalled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled error: %v", err)
|
||||
}
|
||||
if result.Available {
|
||||
t.Error("Available=true without hermes on PATH; discovery must not imply install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesACP_LogosNonEmpty(t *testing.T) {
|
||||
a := NewHermesACP()
|
||||
if len(a.Logo(LogoLight)) == 0 {
|
||||
t.Error("Logo(LogoLight) is empty")
|
||||
}
|
||||
if len(a.Logo(LogoDark)) == 0 {
|
||||
t.Error("Logo(LogoDark) is empty")
|
||||
}
|
||||
if !strings.Contains(string(a.Logo(LogoLight)), "<svg") {
|
||||
t.Error("Logo(LogoLight) is not SVG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesACP_RemoteAuth(t *testing.T) {
|
||||
auth := NewHermesACP().RemoteAuth()
|
||||
if auth == nil {
|
||||
t.Fatal("RemoteAuth() returned nil")
|
||||
}
|
||||
if len(auth.Methods) != 1 {
|
||||
t.Fatalf("Methods len = %d, want 1", len(auth.Methods))
|
||||
}
|
||||
|
||||
files := auth.Methods[0]
|
||||
if files.Type != "files" {
|
||||
t.Errorf("Methods[0].Type = %q, want files", files.Type)
|
||||
}
|
||||
if files.TargetRelDir != ".hermes" {
|
||||
t.Errorf("TargetRelDir = %q, want .hermes", files.TargetRelDir)
|
||||
}
|
||||
for _, osName := range []string{"darwin", "linux"} {
|
||||
paths := files.SourceFiles[osName]
|
||||
want := []string{".hermes/.env", ".hermes/config.yaml"}
|
||||
if !slices.Equal(paths, want) {
|
||||
t.Errorf("SourceFiles[%s] = %#v, want %#v", osName, paths, want)
|
||||
}
|
||||
for _, p := range paths {
|
||||
if strings.Contains(p, "state.db") || strings.Contains(p, "skills") {
|
||||
t.Errorf("must not copy non-auth state: %q", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesACP_LoginCommand(t *testing.T) {
|
||||
cmd := NewHermesACP().LoginCommand()
|
||||
if cmd == nil {
|
||||
t.Fatal("LoginCommand() returned nil")
|
||||
}
|
||||
want := []string{"hermes", "model"}
|
||||
if !slices.Equal(cmd.Cmd, want) {
|
||||
t.Errorf("LoginCommand.Cmd = %#v, want %#v", cmd.Cmd, want)
|
||||
}
|
||||
if cmd.Description == "" {
|
||||
t.Error("LoginCommand.Description is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesACP_SessionAndSkills(t *testing.T) {
|
||||
rt := NewHermesACP().Runtime()
|
||||
if rt.WorkingDir != "{workspace}" {
|
||||
t.Errorf("WorkingDir = %q, want {workspace}", rt.WorkingDir)
|
||||
}
|
||||
if rt.UserSkillDir != ".hermes/skills" {
|
||||
t.Errorf("UserSkillDir = %q, want .hermes/skills", rt.UserSkillDir)
|
||||
}
|
||||
sc := rt.SessionConfig
|
||||
if !sc.NativeSessionResume {
|
||||
t.Error("NativeSessionResume = false, want true")
|
||||
}
|
||||
if sc.NewSessionOnWorkspaceRebind {
|
||||
t.Error("NewSessionOnWorkspaceRebind = true, want false (Hermes reloads sessions with the new cwd)")
|
||||
}
|
||||
if sc.CanRecover == nil || !*sc.CanRecover {
|
||||
t.Error("CanRecover must be true")
|
||||
}
|
||||
if sc.SessionDirTemplate != "{home}/.hermes" {
|
||||
t.Errorf("SessionDirTemplate = %q, want {home}/.hermes", sc.SessionDirTemplate)
|
||||
}
|
||||
if sc.SessionDirTarget != "/root/.hermes" {
|
||||
t.Errorf("SessionDirTarget = %q, want /root/.hermes", sc.SessionDirTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesACP_PermissionAndBillingDefaults(t *testing.T) {
|
||||
a := NewHermesACP()
|
||||
if len(a.PermissionSettings()) != 0 {
|
||||
t.Errorf("PermissionSettings() = %#v, want empty (agentctl auto-approve is authoritative)", a.PermissionSettings())
|
||||
}
|
||||
if got := a.BillingType(); got != usage.BillingTypeAPIKey {
|
||||
t.Errorf("BillingType() = %q, want %q", got, usage.BillingTypeAPIKey)
|
||||
}
|
||||
catalog := CatalogPermissionSettings(a)
|
||||
auto, ok := catalog[PermissionKeyAutoApprove]
|
||||
if !ok {
|
||||
t.Fatal("catalog missing auto_approve")
|
||||
}
|
||||
if auto.ApplyMethod != PermissionApplyMethodAgentctlAutoApprove {
|
||||
t.Errorf("auto_approve ApplyMethod = %q, want agentctl auto-approve", auto.ApplyMethod)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><rect x="4.5" y="4" width="3" height="16" rx="1" fill="#f5f5f5"/><rect x="16.5" y="4" width="3" height="16" rx="1" fill="#f5f5f5"/><rect x="4.5" y="10" width="15" height="4" rx="1" fill="#f5f5f5"/></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><rect x="4.5" y="4" width="3" height="16" rx="1" fill="#171717"/><rect x="16.5" y="4" width="3" height="16" rx="1" fill="#171717"/><rect x="4.5" y="10" width="15" height="4" rx="1" fill="#171717"/></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -150,6 +150,14 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null ||
|
||||
stripEnv: []string{"ACP_BACKEND"},
|
||||
sessionDirTemplate: "{home}/.local/share/devin",
|
||||
}},
|
||||
{func() Agent { return NewHermesACP() }, acpAgentSpec{
|
||||
id: "hermes-acp", displayName: "Hermes", detectBinaries: []string{"hermes"},
|
||||
expectedArgv: []string{"hermes", "acp"},
|
||||
inferenceArgv: []string{"hermes", "acp"},
|
||||
passthroughArgv: []string{"hermes"},
|
||||
installViaNpm: false,
|
||||
sessionDirTemplate: "{home}/.hermes",
|
||||
}},
|
||||
}
|
||||
|
||||
func TestNewACPAgents_IDAndDisplay(t *testing.T) {
|
||||
|
||||
@@ -624,6 +624,7 @@ func buildProbeRequest(
|
||||
Command: probeCommand.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: inst.workDir,
|
||||
Env: agents.RuntimeEnvFor(ia),
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,6 +31,72 @@ func TestManagerExposesRefreshWithCommand(t *testing.T) {
|
||||
require.True(t, ok, "managed runtime updates need a command-override refresh")
|
||||
}
|
||||
|
||||
func TestBuildProbeRequestIncludesRuntimeEnv(t *testing.T) {
|
||||
const envKey = "HERMES_ACP_SKIP_CONFIGURED_MCP"
|
||||
ia := &installedInferenceAgent{
|
||||
id: "hermes-acp",
|
||||
runtimeEnv: map[string]string{envKey: "1"},
|
||||
}
|
||||
req := buildProbeRequest(&instance{agentType: ia.id, workDir: t.TempDir()}, ia, false, agents.Command{})
|
||||
|
||||
payload, err := json.Marshal(req)
|
||||
require.NoError(t, err)
|
||||
var decoded struct {
|
||||
InferenceConfig struct {
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"inference_config"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(payload, &decoded))
|
||||
require.Equal(t, "1", decoded.InferenceConfig.Env[envKey])
|
||||
}
|
||||
|
||||
func TestExecutePromptWithMCPIncludesRuntimeEnv(t *testing.T) {
|
||||
const envKey = "HERMES_ACP_SKIP_CONFIGURED_MCP"
|
||||
log := newTestLogger(t)
|
||||
reg := registry.NewRegistry(log)
|
||||
ia := &installedInferenceAgent{
|
||||
id: "hermes-acp",
|
||||
runtimeEnv: map[string]string{envKey: "1"},
|
||||
}
|
||||
require.NoError(t, reg.Register(ia))
|
||||
|
||||
receivedEnv := make(chan map[string]string, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/api/v1/inference/prompt":
|
||||
var request struct {
|
||||
InferenceConfig struct {
|
||||
Env map[string]string `json:"env"`
|
||||
} `json:"inference_config"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
||||
receivedEnv <- request.InferenceConfig.Env
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.PromptResponse{
|
||||
Success: true,
|
||||
Response: "ok",
|
||||
}))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
host, port := serverHostPort(t, server)
|
||||
|
||||
mgr := NewManager(reg, host, port, nil, log)
|
||||
mgr.instances[ia.id] = &instance{
|
||||
agentType: ia.id,
|
||||
workDir: t.TempDir(),
|
||||
client: agentctlclient.NewClient(host, port, log),
|
||||
}
|
||||
|
||||
_, err := mgr.ExecutePromptWithMCP(context.Background(), ia.id, "", "", "test", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1", (<-receivedEnv)[envKey])
|
||||
}
|
||||
|
||||
func TestRefreshWithCommandUsesOverrideAndPreservesCacheOnAuthFailure(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
reg := registry.NewRegistry(log)
|
||||
@@ -522,7 +588,8 @@ func newTestLogger(t *testing.T) *logger.Logger {
|
||||
}
|
||||
|
||||
type installedInferenceAgent struct {
|
||||
id string
|
||||
id string
|
||||
runtimeEnv map[string]string
|
||||
}
|
||||
|
||||
func (a *installedInferenceAgent) ID() string { return a.id }
|
||||
@@ -544,7 +611,7 @@ func (a *installedInferenceAgent) PermissionSettings() map[string]agents.Permiss
|
||||
return nil
|
||||
}
|
||||
func (a *installedInferenceAgent) Runtime() *agents.RuntimeConfig {
|
||||
return &agents.RuntimeConfig{Protocol: agent.ProtocolACP}
|
||||
return &agents.RuntimeConfig{Protocol: agent.ProtocolACP, Env: a.runtimeEnv}
|
||||
}
|
||||
func (a *installedInferenceAgent) BillingType() usage.BillingType {
|
||||
return usage.BillingTypeSubscription
|
||||
|
||||
@@ -115,6 +115,7 @@ func (m *Manager) ExecutePromptWithMCP(
|
||||
Command: cfg.Command.Args(),
|
||||
ModelFlag: cfg.ModelFlag.Args(),
|
||||
WorkDir: inst.workDir,
|
||||
Env: agents.RuntimeEnvFor(ia),
|
||||
StripEnv: agents.StripEnvFor(ia),
|
||||
},
|
||||
MCPServers: mcpServers,
|
||||
|
||||
@@ -65,6 +65,7 @@ func (r *Registry) LoadDefaults() {
|
||||
agents.NewOmpACP(),
|
||||
agents.NewDevinACP(),
|
||||
agents.NewGrokACP(),
|
||||
agents.NewHermesACP(),
|
||||
agents.NewMockAgent(),
|
||||
}
|
||||
|
||||
|
||||
@@ -790,6 +790,7 @@ var allowedProbeCommands = map[string]string{
|
||||
"cursor-agent": "cursor-agent",
|
||||
"devin": "devin",
|
||||
"grok": "grok",
|
||||
"hermes": "hermes",
|
||||
"kimi": "kimi",
|
||||
"kiro-cli-chat": "kiro-cli-chat",
|
||||
"mock-agent": "mock-agent",
|
||||
@@ -813,6 +814,10 @@ func resolveProbeCommand(name string) string {
|
||||
func sanitizeEnvForAgent(cfg *InferenceConfigDTO) []string {
|
||||
env := os.Environ()
|
||||
if cfg != nil {
|
||||
for key, value := range cfg.Env {
|
||||
env = RemoveEnvEntry(env, key)
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
for _, key := range cfg.StripEnv {
|
||||
env = RemoveEnvEntry(env, key)
|
||||
}
|
||||
|
||||
@@ -531,3 +531,19 @@ func TestSanitizeEnvForAgent(t *testing.T) {
|
||||
t.Errorf("TEST_KEEP_ME was stripped but should have been kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvForAgentAppliesRuntimeOverrides(t *testing.T) {
|
||||
const envKey = "HERMES_ACP_SKIP_CONFIGURED_MCP"
|
||||
t.Setenv(envKey, "0")
|
||||
|
||||
var cfg InferenceConfigDTO
|
||||
err := json.Unmarshal([]byte(`{"env":{"HERMES_ACP_SKIP_CONFIGURED_MCP":"1"}}`), &cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal inference config: %v", err)
|
||||
}
|
||||
|
||||
env := sanitizeEnvForAgent(&cfg)
|
||||
if !slices.Contains(env, envKey+"=1") {
|
||||
t.Errorf("runtime override missing from subprocess env: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,8 @@ type InferenceConfigDTO struct {
|
||||
ModelFlag []string `json:"model_flag,omitempty"`
|
||||
// WorkDir is the working directory for the agent process.
|
||||
WorkDir string `json:"work_dir"`
|
||||
// Env lists agent runtime environment overrides for the inference subprocess.
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
// StripEnv lists environment variables to strip from the inference
|
||||
// subprocess environment entirely (not just set to empty).
|
||||
StripEnv []string `json:"strip_env,omitempty"`
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ This page expands the short feature list in the README without turning the READM
|
||||
|
||||
## Agent Interfaces
|
||||
|
||||
- **ACP agents:** Kandev supports ACP-native and ACP-adapter agents such as Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, Oh My Pi, and Grok.
|
||||
- **ACP agents:** Kandev supports ACP-native and ACP-adapter agents such as Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode, Cursor, Qwen, Factory Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, Oh My Pi, Grok, and Hermes.
|
||||
- **Bring-your-own TUI agents:** any agent CLI can run in a PTY terminal through CLI passthrough, even without ACP support.
|
||||
- **Voice mode:** dictate chat prompts from the composer. Supported engines include browser Web Speech, local in-browser Whisper Web, and server-side Whisper. Settings include language, click-to-toggle or hold-to-talk activation, auto-send, Whisper model size, and keyboard shortcut.
|
||||
- **Utility agents:** one-shot helpers can generate or improve prompt text, branch names, commit messages, commit descriptions, PR titles, PR descriptions, and session summaries. Users can choose a default model, override models per action, and add custom utility agents.
|
||||
|
||||
@@ -13,7 +13,9 @@ Agent authentication is separate from repository credentials and from credential
|
||||
|
||||
Open **Settings > Agents** (`/settings/agents`). Kandev scans the host on which its backend runs, not the browser computer.
|
||||
|
||||
The production registry currently shows Auggie, Claude, Codex, Copilot, Gemini, OpenCode, Amp, Qwen, iFlow (beta), Droid, Kilocode, Pi, Cursor, Kimi, Kiro, Qoder, Trae, `omp`, Devin, and Grok. An entry is usable only when its executable is supported on the current platform and available to the Kandev process. Development and E2E profiles can add mock agents that are not product integrations.
|
||||
The production registry currently shows Auggie, Claude, Codex, Copilot, Gemini, OpenCode, Amp, Qwen, iFlow (beta), Droid, Kilocode, Pi, Cursor, Kimi, Kiro, Qoder, Trae, `omp`, Devin, Grok, and Hermes. An entry is usable only when its executable is supported on the current platform and available to the Kandev process. Development and E2E profiles can add mock agents that are not product integrations.
|
||||
|
||||
Hermes launches with `hermes acp`. Install the required `hermes` executable from its **Settings > Agents** card, which runs the official Hermes installer. Hermes currently supports task and workspace sessions. Office-assigned skill injection is not yet supported.
|
||||
|
||||
1. Select **Rescan** after installing or updating a CLI.
|
||||
2. If the card offers an install action, review the command before running it. Installation runs on the Kandev host.
|
||||
|
||||
Reference in New Issue
Block a user