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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ $env:QINIU_API_KEY = "your_key_here"
go run ./cmd/neocode --workdir /path/to/workspace
```

运行模式切换(默认 `local`):

```bash
go run ./cmd/neocode --runtime-mode local
go run ./cmd/neocode --runtime-mode gateway
```

说明:

- `--runtime-mode` 仅影响当前进程,不会回写 `config.yaml`
- `gateway` 模式会通过本地 Gateway(优先 IPC)转发 runtime 请求与事件流
- 若 Gateway 不可达或握手失败会直接报错退出(Fail Fast),不会自动回退到 `local`

### 4) 首次使用与常用命令
- `/help`:查看命令帮助
- `/provider`:打开 provider 选择器
Expand Down Expand Up @@ -127,6 +140,7 @@ go run ./cmd/neocode --workdir /path/to/workspace

- API Key 通过环境变量注入,不写入 `config.yaml`
- `--workdir` 只影响当前运行,不会回写到配置文件
- `--runtime-mode` 默认 `local`,用于灰度切换到 `gateway` 模式

详细配置请参考:[docs/guides/configuration.md](docs/guides/configuration.md)

Expand Down
9 changes: 7 additions & 2 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,19 +240,24 @@ $env:GEMINI_API_KEY = "AI..."

不要把这两层职责混在一起理解。

## CLI Workdir 覆盖
## CLI 运行参数覆盖

工作目录不写入 `config.yaml`,只通过启动参数覆盖:
工作目录与运行模式都不写入 `config.yaml`,只通过启动参数覆盖:

```bash
go run ./cmd/neocode --workdir /path/to/workspace
go run ./cmd/neocode --runtime-mode local
go run ./cmd/neocode --runtime-mode gateway
```

说明:

- `--workdir` 只影响本次进程
- 不会回写到 `config.yaml`
- 工具根目录与 session 隔离都会使用该工作区
- `--runtime-mode` 默认为 `local`,可切换为 `gateway`
- `gateway` 模式会通过本地 Gateway(优先 IPC)转发 runtime 请求
- 连接或握手失败会直接退出(Fail Fast),不会自动回退到 `local`

## 常见错误

Expand Down
62 changes: 59 additions & 3 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"context"
"errors"
"log"
"path/filepath"
"strings"
Expand Down Expand Up @@ -29,14 +30,23 @@ import (
"neo-code/internal/tools/todo"
"neo-code/internal/tools/webfetch"
"neo-code/internal/tui"
"neo-code/internal/tui/services"
)

const utf8CodePage = 65001

const (
// RuntimeModeLocal 表示继续使用进程内 runtime 直连模式。
RuntimeModeLocal = "local"
// RuntimeModeGateway 表示通过 Gateway JSON-RPC 转发 runtime 调用。
RuntimeModeGateway = "gateway"
)

var (
setConsoleOutputCodePage = platformSetConsoleOutputCodePage
setConsoleInputCodePage = platformSetConsoleInputCodePage
buildToolManagerFunc = buildToolManager
newRemoteRuntimeAdapter = defaultNewRemoteRuntimeAdapter
newTUIWithMemo = tui.NewWithMemo
cleanupExpiredSessions = func(
ctx context.Context,
Expand All @@ -49,13 +59,19 @@ var (

// BootstrapOptions 描述应用启动时可注入的运行时选项。
type BootstrapOptions struct {
Workdir string
Workdir string
RuntimeMode string
}

type memoExtractorScheduler interface {
ScheduleWithExtractor(sessionID string, messages []providertypes.Message, extractor memo.Extractor)
}

type runtimeWithClose interface {
agentruntime.Runtime
Close() error
}

func newMemoExtractorAdapter(
factory agentruntime.ProviderFactory,
cm *config.Manager,
Expand Down Expand Up @@ -114,6 +130,11 @@ func EnsureConsoleUTF8() {

// BuildRuntime 构建 CLI 与 TUI 共用的运行时依赖。
func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, error) {
runtimeMode, err := resolveBootstrapRuntimeMode(opts.RuntimeMode)
if err != nil {
return RuntimeBundle{}, err
}

defaultCfg, err := bootstrapDefaultConfig(opts)
if err != nil {
return RuntimeBundle{}, err
Expand Down Expand Up @@ -210,14 +231,26 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
memo.NewAutoExtractor(nil, memoSvc, time.Duration(cfg.Memo.ExtractTimeoutSec)*time.Second),
))
}

runtimeImpl := agentruntime.Runtime(runtimeSvc)
closeFns := []func() error{toolsCleanup, sessionStore.Close}
if runtimeMode == RuntimeModeGateway {
remoteRuntime, remoteErr := newRemoteRuntimeAdapter(services.RemoteRuntimeAdapterOptions{})
if remoteErr != nil {
return RuntimeBundle{}, remoteErr
}
runtimeImpl = remoteRuntime
closeFns = append([]func() error{remoteRuntime.Close}, closeFns...)
}

needCleanup = false

closeBundle := combineRuntimeClosers(toolsCleanup, sessionStore.Close)
closeBundle := combineRuntimeClosers(closeFns...)

return RuntimeBundle{
Config: cfg,
ConfigManager: manager,
Runtime: runtimeSvc,
Runtime: runtimeImpl,
ProviderSelection: providerSelection,
MemoService: memoSvc,
Close: closeBundle,
Expand Down Expand Up @@ -266,6 +299,20 @@ func resolveBootstrapWorkdir(workdir string) (string, error) {
return agentsession.ResolveExistingDir(workdir)
}

// resolveBootstrapRuntimeMode 归一化并校验 runtime 运行模式。
func resolveBootstrapRuntimeMode(mode string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(mode))
if normalized == "" {
return RuntimeModeLocal, nil
}
switch normalized {
case RuntimeModeLocal, RuntimeModeGateway:
return normalized, nil
default:
return "", errors.New("bootstrap: runtime mode must be local or gateway")
}
}

func buildToolRegistry(cfg config.Config) (*tools.Registry, func() error, error) {
toolRegistry := tools.NewRegistry()
toolRegistry.Register(filesystem.New(cfg.Workdir))
Expand Down Expand Up @@ -323,6 +370,15 @@ func buildMCPAgentExposureRules(configs []config.MCPAgentExposureConfig) []mcp.A
return rules
}

// defaultNewRemoteRuntimeAdapter 构建默认的 Gateway runtime 适配器。
func defaultNewRemoteRuntimeAdapter(options services.RemoteRuntimeAdapterOptions) (runtimeWithClose, error) {
adapter, err := services.NewRemoteRuntimeAdapter(options)
if err != nil {
return nil, err
}
return adapter, nil
}

func buildToolManager(registry *tools.Registry) (tools.Manager, error) {
engine, err := security.NewRecommendedPolicyEngine()
if err != nil {
Expand Down
173 changes: 173 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"neo-code/internal/tools"
"neo-code/internal/tools/mcp"
"neo-code/internal/tui"
"neo-code/internal/tui/services"
)

func TestNewProgram(t *testing.T) {
Expand Down Expand Up @@ -1439,11 +1440,183 @@ func TestNewMemoExtractorAdapterPropagatesFactoryBuildError(t *testing.T) {
}
}

func TestResolveBootstrapRuntimeMode(t *testing.T) {
mode, err := resolveBootstrapRuntimeMode("")
if err != nil {
t.Fatalf("resolveBootstrapRuntimeMode() error = %v", err)
}
if mode != RuntimeModeLocal {
t.Fatalf("expected default mode %q, got %q", RuntimeModeLocal, mode)
}

mode, err = resolveBootstrapRuntimeMode(" GATEWAY ")
if err != nil {
t.Fatalf("resolveBootstrapRuntimeMode() error = %v", err)
}
if mode != RuntimeModeGateway {
t.Fatalf("expected gateway mode %q, got %q", RuntimeModeGateway, mode)
}

_, err = resolveBootstrapRuntimeMode("invalid")
if err == nil {
t.Fatalf("expected invalid runtime mode error")
}
}

func TestBuildRuntimeRejectsInvalidRuntimeMode(t *testing.T) {
t.Parallel()

_, err := BuildRuntime(context.Background(), BootstrapOptions{RuntimeMode: "invalid"})
if err == nil {
t.Fatalf("expected invalid runtime mode error")
}
}

func TestDefaultNewRemoteRuntimeAdapterReturnsInitError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

_, err := defaultNewRemoteRuntimeAdapter(services.RemoteRuntimeAdapterOptions{
ListenAddress: "ipc://127.0.0.1",
TokenFile: home + "/missing-token.json",
})
if err == nil {
t.Fatalf("expected defaultNewRemoteRuntimeAdapter to fail when token is missing")
}
}

func TestBuildRuntimeGatewayModeUsesRemoteAdapter(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

originalFactory := newRemoteRuntimeAdapter
t.Cleanup(func() { newRemoteRuntimeAdapter = originalFactory })

stubRuntime := &stubRemoteRuntimeForBootstrap{
events: make(chan agentruntime.RuntimeEvent),
}
newRemoteRuntimeAdapter = func(_ services.RemoteRuntimeAdapterOptions) (runtimeWithClose, error) {
return stubRuntime, nil
}

bundle, err := BuildRuntime(context.Background(), BootstrapOptions{RuntimeMode: RuntimeModeGateway})
if err != nil {
t.Fatalf("BuildRuntime() error = %v", err)
}
if bundle.Runtime != stubRuntime {
t.Fatalf("expected gateway runtime adapter to be wired")
}
if bundle.Close == nil {
t.Fatalf("expected non-nil close function")
}
if err := bundle.Close(); err != nil {
t.Fatalf("bundle.Close() error = %v", err)
}
if !stubRuntime.closed {
t.Fatalf("expected remote runtime close to be called")
}
}

func TestBuildRuntimeGatewayModeFailsFastWhenAdapterInitFails(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

originalFactory := newRemoteRuntimeAdapter
t.Cleanup(func() { newRemoteRuntimeAdapter = originalFactory })

newRemoteRuntimeAdapter = func(_ services.RemoteRuntimeAdapterOptions) (runtimeWithClose, error) {
return nil, errors.New("gateway connect failed")
}

_, err := BuildRuntime(context.Background(), BootstrapOptions{RuntimeMode: RuntimeModeGateway})
if err == nil {
t.Fatalf("expected gateway mode fail-fast error")
}
if !strings.Contains(err.Error(), "gateway connect failed") {
t.Fatalf("unexpected error: %v", err)
}
}

type stubToolForBootstrap struct {
name string
content string
}

type stubRemoteRuntimeForBootstrap struct {
closed bool
events chan agentruntime.RuntimeEvent
}

func (s *stubRemoteRuntimeForBootstrap) Submit(context.Context, agentruntime.PrepareInput) error {
return nil
}

func (s *stubRemoteRuntimeForBootstrap) PrepareUserInput(
context.Context,
agentruntime.PrepareInput,
) (agentruntime.UserInput, error) {
return agentruntime.UserInput{}, nil
}

func (s *stubRemoteRuntimeForBootstrap) Run(context.Context, agentruntime.UserInput) error {
return nil
}

func (s *stubRemoteRuntimeForBootstrap) Compact(context.Context, agentruntime.CompactInput) (agentruntime.CompactResult, error) {
return agentruntime.CompactResult{}, nil
}

func (s *stubRemoteRuntimeForBootstrap) ExecuteSystemTool(
context.Context,
agentruntime.SystemToolInput,
) (tools.ToolResult, error) {
return tools.ToolResult{}, nil
}

func (s *stubRemoteRuntimeForBootstrap) ResolvePermission(context.Context, agentruntime.PermissionResolutionInput) error {
return nil
}

func (s *stubRemoteRuntimeForBootstrap) CancelActiveRun() bool {
return false
}

func (s *stubRemoteRuntimeForBootstrap) Events() <-chan agentruntime.RuntimeEvent {
return s.events
}

func (s *stubRemoteRuntimeForBootstrap) ListSessions(context.Context) ([]agentsession.Summary, error) {
return nil, nil
}

func (s *stubRemoteRuntimeForBootstrap) LoadSession(context.Context, string) (agentsession.Session, error) {
return agentsession.Session{}, nil
}

func (s *stubRemoteRuntimeForBootstrap) ActivateSessionSkill(context.Context, string, string) error {
return nil
}

func (s *stubRemoteRuntimeForBootstrap) DeactivateSessionSkill(context.Context, string, string) error {
return nil
}

func (s *stubRemoteRuntimeForBootstrap) ListSessionSkills(context.Context, string) ([]agentruntime.SessionSkillState, error) {
return nil, nil
}

func (s *stubRemoteRuntimeForBootstrap) Close() error {
s.closed = true
return nil
}

func (s stubToolForBootstrap) Name() string { return s.name }
func (s stubToolForBootstrap) Description() string { return "stub" }
func (s stubToolForBootstrap) Schema() map[string]any { return map[string]any{"type": "object"} }
Expand Down
Loading
Loading