diff --git a/README.md b/README.md index ae01002..f61a940 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,15 @@ AI-native log pattern discovery: cluster logs cheaply, semantify templates with # Build go build ./cmd/lapp/ -# Ingest logs (stdin or file) -cat app.log | go run ./cmd/lapp/ ingest - --db lapp.duckdb -go run ./cmd/lapp/ ingest /var/log/syslog --db lapp.duckdb +# Create a workspace +go run ./cmd/lapp/ workspace create app-incident -# View discovered templates -go run ./cmd/lapp/ templates --db lapp.duckdb +# Add logs to the workspace (semantic labeling via OpenRouter) +go run ./cmd/lapp/ workspace add-log --topic app-incident /var/log/syslog -# Query logs by template -go run ./cmd/lapp/ query --template D1 --db lapp.duckdb - -# AI-powered analysis (requires OPENROUTER_API_KEY) -go run ./cmd/lapp/ analyze app.log "why are there connection timeouts?" +# AI-powered analysis (agent backend via ACP provider) +go run ./cmd/lapp/ workspace analyze --topic app-incident "why are there connection timeouts?" --acp claude +go run ./cmd/lapp/ workspace analyze --topic app-incident "what failed?" --acp codex ``` ## How It Works @@ -40,27 +37,26 @@ Parser Chain (first match wins) DuckDB Store (log_entries: line_number, raw, template_id, template) │ ▼ -Query / Analyze +Workspace Notes / Analyze ``` **Core idea**: Drain clusters logs into templates cheaply (no API cost), then LLM semantifies the templates in a single call. This follows the IBM "Label Broadcasting" pattern — cluster first (90%+ volume reduction), apply LLM to representatives, broadcast labels back. ## Environment Variables -- `OPENROUTER_API_KEY`: Required for `analyze` and `debug run` commands +- `OPENROUTER_API_KEY`: Required for semantic labeling in `workspace add-log` - `MODEL_NAME`: Override default LLM model (default: `google/gemini-3-flash-preview`) +- Provider-specific auth for ACP agent CLI (for example Claude/Codex/Gemini CLI login credentials) - `.env` file is auto-loaded ## Commands | Command | Description | |---|---| -| `ingest ` | Parse log file, store in DuckDB | -| `templates` | Show discovered templates with counts | -| `query --template ` | Filter logs by template ID | -| `analyze [question]` | AI-powered log analysis | -| `debug workspace ` | Build analysis workspace without LLM | -| `debug run [question]` | Run AI agent on existing workspace | +| `workspace create ` | Create a workspace under `~/.lapp/workspaces/` | +| `workspace list` | List all workspace topics | +| `workspace add-log --topic ` | Add log file and rebuild patterns/notes | +| `workspace analyze --topic [question]` | Run AI analysis (`--acp claude|codex|gemini`) | ## Development diff --git a/cmd/lapp/workspace.go b/cmd/lapp/workspace.go index 423437f..e797954 100644 --- a/cmd/lapp/workspace.go +++ b/cmd/lapp/workspace.go @@ -347,6 +347,7 @@ func resetWorkspaceDirs(dir string) error { } var analyzeWsModel string +var analyzeWsACP string var analyzeTopic string func workspaceAnalyzeCmd() *cobra.Command { @@ -355,12 +356,13 @@ func workspaceAnalyzeCmd() *cobra.Command { Short: "Run an AI agent to analyze the workspace", Long: `Run an AI agent on a structured workspace directory to analyze logs. -Requires OPENROUTER_API_KEY environment variable.`, +Use --acp to choose ACP agent backend (claude/codex/gemini).`, Args: cobra.MaximumNArgs(1), RunE: runWorkspaceAnalyze, } cmd.Flags().StringVar(&analyzeTopic, "topic", "", "workspace topic (required)") - cmd.Flags().StringVar(&analyzeWsModel, "model", "", "override LLM model") + cmd.Flags().StringVar(&analyzeWsModel, "model", "", "override ACP agent model (passed as --model to provider command)") + cmd.Flags().StringVar(&analyzeWsACP, "acp", analyzer.ProviderClaude, "ACP agent provider: claude|codex|gemini") _ = cmd.MarkFlagRequired("topic") return cmd } @@ -377,11 +379,6 @@ func runWorkspaceAnalyze(cmd *cobra.Command, args []string) error { return errors.Errorf("not a workspace: %s (no patterns/ directory)%s", dir, hint) } - apiKey := os.Getenv("OPENROUTER_API_KEY") - if apiKey == "" { - return errors.New("OPENROUTER_API_KEY environment variable is required") - } - var question string if len(args) > 0 { question = args[0] @@ -396,8 +393,8 @@ func runWorkspaceAnalyze(cmd *cobra.Command, args []string) error { } config := analyzer.Config{ - APIKey: apiKey, - Model: analyzeWsModel, + Provider: analyzeWsACP, + Model: analyzeWsModel, } prompt := analyzer.BuildWorkspaceSystemPrompt(absDir) diff --git a/go.mod b/go.mod index 6106b53..61b2a44 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/strrl/lapp go 1.25.7 require ( - github.com/cloudwego/eino v0.7.35 - github.com/cloudwego/eino-ext/adk/backend/local v0.1.1 + github.com/cloudwego/eino v0.8.0 + github.com/cloudwego/eino-ext/adk/backend/local v0.1.2-0.20260306073537-008f82264d85 github.com/cloudwego/eino-ext/callbacks/langfuse v0.0.0-20260227151421-e109b4ff9563 github.com/cloudwego/eino-ext/components/model/openrouter v0.1.2 github.com/duckdb/duckdb-go/v2 v2.5.5 @@ -13,6 +13,7 @@ require ( github.com/jaeyo/go-drain3 v0.1.2 github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.10.2 + github.com/strrl/eino-acp v0.0.0-20260310032205-4c84efa27879 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 @@ -23,16 +24,18 @@ require ( require ( github.com/apache/arrow-go/v18 v18.5.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.14.1 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/eino-ext/libs/acl/langfuse v0.0.0-20251124083837-ce2e7e196f9f // indirect github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 // indirect + github.com/coder/acp-go-sdk v0.6.3 // indirect github.com/duckdb/duckdb-go-bindings v0.3.3 // indirect github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3 // indirect github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.3.3 // indirect diff --git a/go.sum b/go.sum index 49b6f0f..8cc2289 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJe github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= @@ -17,10 +19,10 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY= -github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= -github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -30,10 +32,10 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cloudwego/eino v0.7.35 h1:UpZwHQNh8qgGRxKk2Zzdef68Holk8wPVpwjRDbiLOY8= -github.com/cloudwego/eino v0.7.35/go.mod h1:nA8Vacmuqv3pqKBQbTWENBLQ8MmGmPt/WqiyLeB8ohQ= -github.com/cloudwego/eino-ext/adk/backend/local v0.1.1 h1:cfagscQRuNH52Rptc2JPIb+4n2Agb6bxUt4RY2xJMrY= -github.com/cloudwego/eino-ext/adk/backend/local v0.1.1/go.mod h1:LfFk+VqZk0JOxIyl5RaerYqlFVLyXOCoSaqqak8hNls= +github.com/cloudwego/eino v0.8.0 h1:DLbrgEAloA+l7aR2qim7qQocQB48DjPrb8LzG3PYMHY= +github.com/cloudwego/eino v0.8.0/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU= +github.com/cloudwego/eino-ext/adk/backend/local v0.1.2-0.20260306073537-008f82264d85 h1:mD47o0GKdeqMdGI5xEqnlO8ZtArvhalIorRtrCmLRkA= +github.com/cloudwego/eino-ext/adk/backend/local v0.1.2-0.20260306073537-008f82264d85/go.mod h1:LfFk+VqZk0JOxIyl5RaerYqlFVLyXOCoSaqqak8hNls= github.com/cloudwego/eino-ext/callbacks/langfuse v0.0.0-20260227151421-e109b4ff9563 h1:DKTXDDw8ErC4RorZLfB2ZdHChjDKWIqOEO7VRSjjfbg= github.com/cloudwego/eino-ext/callbacks/langfuse v0.0.0-20260227151421-e109b4ff9563/go.mod h1:lrNKITZR4QUaYl9Rdz9W6qGOolHRy6mPamEZYA8uz7s= github.com/cloudwego/eino-ext/components/model/openrouter v0.1.2 h1:zDFteouktUsGk4I/7m1b7yT4e9qawy45gWtLoyeHwxI= @@ -42,6 +44,8 @@ github.com/cloudwego/eino-ext/libs/acl/langfuse v0.0.0-20251124083837-ce2e7e196f github.com/cloudwego/eino-ext/libs/acl/langfuse v0.0.0-20251124083837-ce2e7e196f9f/go.mod h1:P3zzJTRexY0QKaE9Vn2CmOnCorIMgNzNtler8mw9IQM= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns= +github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ= +github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -190,15 +194,18 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/strrl/eino-acp v0.0.0-20260310032205-4c84efa27879 h1:xse8vA0W42NBvZ6ZfVC6BGlgX0uf/eMal5objw0QNLQ= +github.com/strrl/eino-acp v0.0.0-20260310032205-4c84efa27879/go.mod h1:xsRolfWoxdvi9ZQlK8Idgc2+cpZpK8kEoIxywQBOEws= github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= diff --git a/pkg/analyzer/acp_tool_model.go b/pkg/analyzer/acp_tool_model.go new file mode 100644 index 0000000..fdd6033 --- /dev/null +++ b/pkg/analyzer/acp_tool_model.go @@ -0,0 +1,33 @@ +package analyzer + +import ( + "context" + + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + einoacp "github.com/strrl/eino-acp" +) + +var _ model.ToolCallingChatModel = (*acpToolCallingModel)(nil) + +// acpToolCallingModel adapts eino-acp ChatModel to ToolCallingChatModel. +// ACP agents manage tools in their own runtime, so WithTools is a no-op. +type acpToolCallingModel struct { + base *einoacp.ChatModel +} + +func newACPToolCallingModel(base *einoacp.ChatModel) model.ToolCallingChatModel { + return &acpToolCallingModel{base: base} +} + +func (m *acpToolCallingModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) { + return m.base.Generate(ctx, input, opts...) +} + +func (m *acpToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { + return m.base.Stream(ctx, input, opts...) +} + +func (m *acpToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) { + return m, nil +} diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index fa0cd2f..35a8635 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -1,23 +1,17 @@ package analyzer import ( - "bytes" "context" - "encoding/json" "fmt" - "io" "log/slog" - "net/http" "path/filepath" "strings" "github.com/cloudwego/eino-ext/adk/backend/local" - "github.com/cloudwego/eino-ext/components/model/openrouter" "github.com/cloudwego/eino/adk" fsmw "github.com/cloudwego/eino/adk/middlewares/filesystem" "github.com/go-errors/errors" - llmconfig "github.com/strrl/lapp/pkg/config" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + einoacp "github.com/strrl/eino-acp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" ) @@ -49,8 +43,8 @@ Be concise and actionable. Focus on what matters.`, // Config holds configuration for the analyzer. type Config struct { - APIKey string - Model string + Provider string + Model string } // BuildWorkspaceSystemPrompt builds a system prompt for the structured workspace layout. @@ -94,77 +88,68 @@ func RunAgentWithPrompt(ctx context.Context, config Config, workDir, question, s attribute.String("workspace.dir", workDir), ) - config.Model = llmconfig.ResolveModel(config.Model) - span.SetAttributes(attribute.String("model", config.Model)) - absDir, err := filepath.Abs(workDir) if err != nil { return "", errors.Errorf("resolve workspace dir: %w", err) } - slog.Info("Analyzing with model", "model", config.Model) - - // Preflight check: verify API key works - if err := preflightCheck(ctx, config); err != nil { + provider, command, err := BuildACPCommand(config.Provider, config.Model) + if err != nil { return "", err } - // Create OpenRouter chat model with fixup transport to patch eino tool message bug - // Stack: otelhttp (tracing) -> fixupRoundTripper (eino bug workaround) -> http.DefaultTransport - chatModel, err := openrouter.NewChatModel(ctx, &openrouter.Config{ - APIKey: config.APIKey, - Model: config.Model, - HTTPClient: &http.Client{ - Transport: otelhttp.NewTransport(&fixupRoundTripper{base: http.DefaultTransport}), - }, + span.SetAttributes( + attribute.String("provider", provider), + attribute.String("model", config.Model), + ) + + slog.Info("Analyzing with ACP provider", "provider", provider, "model", config.Model) + + chatModel, err := einoacp.NewChatModel(ctx, &einoacp.Config{ + Command: command, + Cwd: absDir, + AutoApprove: true, }) if err != nil { return "", errors.Errorf("create chat model: %w", err) } - // Create local filesystem backend from eino-ext backend, err := local.NewBackend(ctx, &local.Config{}) if err != nil { return "", errors.Errorf("create local backend: %w", err) } + backendAdapter := newLocalBackendAdapter(backend) - // Create filesystem middleware - fsMiddleware, err := fsmw.NewMiddleware(ctx, &fsmw.Config{ - Backend: backend, + fsHandler, err := fsmw.New(ctx, &fsmw.MiddlewareConfig{ + Backend: backendAdapter, + StreamingShell: backendAdapter, }) if err != nil { return "", errors.Errorf("create filesystem middleware: %w", err) } - // Use the provided system prompt, replacing workDir placeholder if needed if systemPrompt == "" { systemPrompt = buildSystemPrompt(absDir) } - // Create agent agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ Name: "log-analyzer", Description: "Analyzes log files to find root causes", Instruction: systemPrompt, - Model: chatModel, - Middlewares: []adk.AgentMiddleware{fsMiddleware}, + Model: newACPToolCallingModel(chatModel), + Handlers: []adk.ChatModelAgentMiddleware{fsHandler}, MaxIterations: 15, }) if err != nil { return "", errors.Errorf("create agent: %w", err) } - // Build user message userMessage := "Analyze the log files in the workspace." if question != "" { userMessage = "Analyze the log files in the workspace. The user's question: " + question } - // Run agent - runner := adk.NewRunner(ctx, adk.RunnerConfig{ - Agent: agent, - }) - + runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: agent}) iter := runner.Query(ctx, userMessage) var result strings.Builder @@ -192,88 +177,3 @@ func RunAgentWithPrompt(ctx context.Context, config Config, workDir, question, s func RunAgent(ctx context.Context, config Config, workDir, question string) (string, error) { return RunAgentWithPrompt(ctx, config, workDir, question, "") } - -// fixupRoundTripper patches outgoing API requests to work around eino bugs. -type fixupRoundTripper struct { - base http.RoundTripper -} - -func (rt *fixupRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - // Patch tool messages missing "content" field before sending to OpenRouter. - // eino omits "content" when a tool returns empty results (e.g. grep with no matches), - // which causes the Anthropic API to return 500. - if req.Body != nil && req.Method == http.MethodPost { - bodyBytes, err := io.ReadAll(req.Body) - if err != nil { - return nil, errors.Errorf("read request body: %w", err) - } - bodyBytes = fixToolMessages(bodyBytes) - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - req.ContentLength = int64(len(bodyBytes)) - } - return rt.base.RoundTrip(req) -} - -func fixToolMessages(body []byte) []byte { - var payload map[string]json.RawMessage - if err := json.Unmarshal(body, &payload); err != nil { - return body - } - messagesRaw, ok := payload["messages"] - if !ok { - return body - } - var messages []map[string]any - if err := json.Unmarshal(messagesRaw, &messages); err != nil { - return body - } - - changed := false - for _, msg := range messages { - if msg["role"] == "tool" { - if _, hasContent := msg["content"]; !hasContent { - msg["content"] = "" - changed = true - } - } - } - if !changed { - return body - } - - fixedMessages, err := json.Marshal(messages) - if err != nil { - return body - } - payload["messages"] = fixedMessages - result, err := json.Marshal(payload) - if err != nil { - return body - } - return result -} - -// preflightCheck does a quick API call to verify the key works. -func preflightCheck(ctx context.Context, config Config) error { - _, span := otel.Tracer("lapp/analyzer").Start(ctx, "analyzer.PreflightCheck") - defer span.End() - - apiURL := "https://openrouter.ai/api/v1/models" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody) - if err != nil { - return errors.Errorf("preflight: %w", err) - } - req.Header.Set("Authorization", "Bearer "+config.APIKey) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return errors.Errorf("preflight: cannot reach OpenRouter: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return errors.Errorf("API error (HTTP %d) from OpenRouter: %s", resp.StatusCode, string(body)) - } - return nil -} diff --git a/pkg/analyzer/local_backend_adapter.go b/pkg/analyzer/local_backend_adapter.go new file mode 100644 index 0000000..22247fe --- /dev/null +++ b/pkg/analyzer/local_backend_adapter.go @@ -0,0 +1,52 @@ +package analyzer + +import ( + "context" + + "github.com/cloudwego/eino-ext/adk/backend/local" + "github.com/cloudwego/eino/adk/filesystem" + "github.com/cloudwego/eino/schema" +) + +var _ filesystem.Backend = (*localBackendAdapter)(nil) +var _ filesystem.StreamingShell = (*localBackendAdapter)(nil) + +type localBackendAdapter struct { + base *local.Local +} + +func newLocalBackendAdapter(base *local.Local) *localBackendAdapter { + return &localBackendAdapter{base: base} +} + +func (a *localBackendAdapter) LsInfo(ctx context.Context, req *filesystem.LsInfoRequest) ([]filesystem.FileInfo, error) { + return a.base.LsInfo(ctx, req) +} + +func (a *localBackendAdapter) Read(ctx context.Context, req *filesystem.ReadRequest) (*filesystem.FileContent, error) { + content, err := a.base.Read(ctx, req) + if err != nil { + return nil, err + } + return &filesystem.FileContent{Content: content}, nil +} + +func (a *localBackendAdapter) GrepRaw(ctx context.Context, req *filesystem.GrepRequest) ([]filesystem.GrepMatch, error) { + return a.base.GrepRaw(ctx, req) +} + +func (a *localBackendAdapter) GlobInfo(ctx context.Context, req *filesystem.GlobInfoRequest) ([]filesystem.FileInfo, error) { + return a.base.GlobInfo(ctx, req) +} + +func (a *localBackendAdapter) Write(ctx context.Context, req *filesystem.WriteRequest) error { + return a.base.Write(ctx, req) +} + +func (a *localBackendAdapter) Edit(ctx context.Context, req *filesystem.EditRequest) error { + return a.base.Edit(ctx, req) +} + +func (a *localBackendAdapter) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) { + return a.base.ExecuteStreaming(ctx, input) +} diff --git a/pkg/analyzer/provider.go b/pkg/analyzer/provider.go new file mode 100644 index 0000000..60f66b6 --- /dev/null +++ b/pkg/analyzer/provider.go @@ -0,0 +1,52 @@ +package analyzer + +import ( + "strings" + + "github.com/go-errors/errors" +) + +const ( + ProviderClaude = "claude" + ProviderCodex = "codex" + ProviderGemini = "gemini" +) + +// BuildACPCommand resolves provider and builds the ACP launcher command. +func BuildACPCommand(provider, model string) (resolvedProvider string, command []string, err error) { + resolved, err := resolveProvider(provider) + if err != nil { + return "", nil, err + } + + switch resolved { + case ProviderClaude: + command = []string{"npx", "-y", "@zed-industries/claude-agent-acp@latest"} + case ProviderCodex: + command = []string{"codex", "--acp"} + case ProviderGemini: + command = []string{"gemini", "--experimental-acp"} + default: + return "", nil, errors.Errorf("unsupported provider %q", resolved) + } + + if model != "" { + command = append(command, "--model", model) + } + + return resolved, command, nil +} + +func resolveProvider(provider string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(provider)) + if normalized == "" { + return ProviderClaude, nil + } + + switch normalized { + case ProviderClaude, ProviderCodex, ProviderGemini: + return normalized, nil + default: + return "", errors.Errorf("invalid provider %q (supported: claude, codex, gemini)", provider) + } +} diff --git a/pkg/analyzer/provider_test.go b/pkg/analyzer/provider_test.go new file mode 100644 index 0000000..50c4290 --- /dev/null +++ b/pkg/analyzer/provider_test.go @@ -0,0 +1,65 @@ +package analyzer + +import ( + "reflect" + "testing" +) + +func TestBuildACPCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provider string + model string + wantProvider string + wantCommand []string + }{ + { + name: "default provider", + provider: "", + wantProvider: ProviderClaude, + wantCommand: []string{"npx", "-y", "@zed-industries/claude-agent-acp@latest"}, + }, + { + name: "codex with model", + provider: ProviderCodex, + model: "gpt-5-codex", + wantProvider: ProviderCodex, + wantCommand: []string{"codex", "--acp", "--model", "gpt-5-codex"}, + }, + { + name: "gemini normalized", + provider: "GeMiNi", + wantProvider: ProviderGemini, + wantCommand: []string{"gemini", "--experimental-acp"}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + provider, command, err := BuildACPCommand(tc.provider, tc.model) + if err != nil { + t.Fatalf("BuildACPCommand() error = %v", err) + } + if provider != tc.wantProvider { + t.Fatalf("provider = %q, want %q", provider, tc.wantProvider) + } + if !reflect.DeepEqual(command, tc.wantCommand) { + t.Fatalf("command = %#v, want %#v", command, tc.wantCommand) + } + }) + } +} + +func TestBuildACPCommand_InvalidProvider(t *testing.T) { + t.Parallel() + + _, _, err := BuildACPCommand("openrouter", "") + if err == nil { + t.Fatal("expected error for invalid provider") + } +}