feat: import agents_and_robots platform as unibots (Matrix-out, unibus transport)
Reemplaza el scaffold del echobot por la plataforma completa de bots traida desde ~/DataProyects/Github/agents_and_robots tras la operacion Matrix-out: los bots ya no hablan por Matrix sino por el bus unibus (modelo todo-rooms + E2E via shell/transportunibus sobre github.com/enmanuel/unibus/pkg/client). - go.mod: replace de unibus -> ../unibus y de fn-registry -> ../../../.. (paths relativos reajustados a la nueva ubicacion dentro de fn_registry). - app.md: bump a 0.2.0, descripcion + arquitectura + comandos + gotchas reales. - modulo Go conservado como github.com/enmanuel/agents (sin reescribir imports). agents_and_robots queda archivado como museo de la era Matrix.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClaudeBinary = "claude"
|
||||
defaultClaudeTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// claudeJSONOutput represents the JSON output from `claude -p --output-format json`.
|
||||
type claudeJSONOutput struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
IsError bool `json:"is_error"`
|
||||
Duration float64 `json:"duration_api_ms"`
|
||||
NumTurns int `json:"num_turns"`
|
||||
Result string `json:"result"`
|
||||
SessionID string `json:"session_id"`
|
||||
TotalCost float64 `json:"total_cost_usd"`
|
||||
Usage claudeUsage `json:"usage"`
|
||||
ContentBlock []claudeContent `json:"content"`
|
||||
}
|
||||
|
||||
type claudeUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
type claudeContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// NewClaudeCodeComplete creates a CompleteFunc that executes `claude -p` as a subprocess.
|
||||
func NewClaudeCodeComplete(cfg config.ClaudeCodeCfg, log *slog.Logger) coretypes.CompleteFunc {
|
||||
binary := cfg.Binary
|
||||
if binary == "" {
|
||||
binary = defaultClaudeBinary
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultClaudeTimeout
|
||||
}
|
||||
|
||||
// Resolve working directory once at init time.
|
||||
workDir := resolveWorkDir(cfg.WorkingDir, log)
|
||||
|
||||
return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
args := buildClaudeArgs(cfg, req)
|
||||
|
||||
prompt := flattenMessages(req.Messages)
|
||||
|
||||
log.Debug("claude_code_exec",
|
||||
"binary", binary,
|
||||
"args", strings.Join(args, " "),
|
||||
"prompt_len", len(prompt),
|
||||
"working_dir", workDir,
|
||||
)
|
||||
|
||||
cmd := exec.CommandContext(ctx, binary, args...)
|
||||
if workDir != "" {
|
||||
cmd.Dir = workDir
|
||||
}
|
||||
// Build clean env: inherit parent but remove ANTHROPIC_API_KEY
|
||||
// so claude uses its own OAuth auth instead of a potentially invalid key.
|
||||
cmd.Env = filterEnv(os.Environ(), "ANTHROPIC_API_KEY")
|
||||
cmd.Stdin = strings.NewReader(prompt)
|
||||
|
||||
// Create a new process group so we can kill claude + all its children.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// Override the default cancel behavior: kill the entire process group
|
||||
// instead of just the main process, preventing orphaned child processes.
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
pgid := cmd.Process.Pid
|
||||
log.Info("killing claude-code process group", "pgid", pgid)
|
||||
// Negative PID = kill entire process group
|
||||
return syscall.Kill(-pgid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
start := time.Now()
|
||||
err := cmd.Run()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Ensure the process group is fully dead after Run returns,
|
||||
// even if cmd.Run() returned without triggering Cancel (normal exit).
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
|
||||
log.Debug("claude_code_done",
|
||||
"elapsed_ms", elapsed.Milliseconds(),
|
||||
"stdout_len", stdout.Len(),
|
||||
"stderr_len", stderr.Len(),
|
||||
"exit_err", err,
|
||||
)
|
||||
|
||||
return parseClaudeOutput(stdout.Bytes(), stderr.Bytes(), err, elapsed, log)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWorkDir determines the working directory for the claude subprocess.
|
||||
// If configured is empty, it creates a temporary directory to avoid inheriting the launcher's CWD.
|
||||
// If configured is non-empty, it ensures the directory exists.
|
||||
func resolveWorkDir(configured string, log *slog.Logger) string {
|
||||
if configured == "" {
|
||||
tmp, err := os.MkdirTemp("", "claude-agent-*")
|
||||
if err != nil {
|
||||
log.Error("claude-code: failed to create temp working dir", "err", err)
|
||||
return "" // Fall through — cmd.Dir will remain empty (inherits CWD).
|
||||
}
|
||||
log.Warn("claude-code working_dir is empty, using temporary directory",
|
||||
"dir", tmp,
|
||||
)
|
||||
return tmp
|
||||
}
|
||||
|
||||
// Ensure configured directory exists.
|
||||
if err := os.MkdirAll(configured, 0o755); err != nil {
|
||||
log.Error("claude-code: failed to create working dir", "dir", configured, "err", err)
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
// buildClaudeArgs constructs the CLI arguments for claude -p.
|
||||
func buildClaudeArgs(cfg config.ClaudeCodeCfg, req coretypes.CompletionRequest) []string {
|
||||
args := []string{"--print", "--output-format", "json"}
|
||||
|
||||
if req.SystemPrompt != "" {
|
||||
args = append(args, "--system-prompt", req.SystemPrompt)
|
||||
}
|
||||
|
||||
if cfg.DisableTools {
|
||||
args = append(args, "--tools", "")
|
||||
} else {
|
||||
if len(cfg.AllowedTools) > 0 {
|
||||
args = append(args, "--allowedTools")
|
||||
args = append(args, cfg.AllowedTools...)
|
||||
}
|
||||
|
||||
if len(cfg.DisallowedTools) > 0 {
|
||||
args = append(args, "--disallowedTools")
|
||||
args = append(args, cfg.DisallowedTools...)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.PermissionMode != "" {
|
||||
args = append(args, "--permission-mode", cfg.PermissionMode)
|
||||
}
|
||||
|
||||
if cfg.Model != "" {
|
||||
args = append(args, "--model", cfg.Model)
|
||||
}
|
||||
|
||||
if cfg.FallbackModel != "" {
|
||||
args = append(args, "--fallback-model", cfg.FallbackModel)
|
||||
}
|
||||
|
||||
if cfg.SessionID != "" {
|
||||
args = append(args, "--session-id", cfg.SessionID)
|
||||
}
|
||||
|
||||
for _, dir := range cfg.AddDirs {
|
||||
args = append(args, "--add-dir", dir)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// flattenMessages converts a conversation history into a single text prompt for stdin.
|
||||
func flattenMessages(msgs []coretypes.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case coretypes.RoleUser:
|
||||
fmt.Fprintf(&b, "User: %s\n\n", m.Content)
|
||||
case coretypes.RoleAssistant:
|
||||
fmt.Fprintf(&b, "Assistant: %s\n\n", m.Content)
|
||||
case coretypes.RoleTool:
|
||||
fmt.Fprintf(&b, "Tool result: %s\n\n", m.Content)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseClaudeOutput parses the JSON output from `claude -p --output-format json`.
|
||||
func parseClaudeOutput(
|
||||
stdout, stderr []byte,
|
||||
execErr error,
|
||||
elapsed time.Duration,
|
||||
log *slog.Logger,
|
||||
) (coretypes.CompletionResponse, error) {
|
||||
// If the process failed and there's no stdout, report the error
|
||||
if execErr != nil && len(stdout) == 0 {
|
||||
errMsg := string(stderr)
|
||||
if errMsg == "" {
|
||||
errMsg = execErr.Error()
|
||||
}
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("claude-code process failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var output claudeJSONOutput
|
||||
if err := json.Unmarshal(stdout, &output); err != nil {
|
||||
// Fall back to treating stdout as plain text
|
||||
log.Warn("claude_code_json_parse_failed", "err", err, "stdout_len", len(stdout))
|
||||
return coretypes.CompletionResponse{
|
||||
Content: strings.TrimSpace(string(stdout)),
|
||||
FinishReason: "stop",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if output.IsError {
|
||||
return coretypes.CompletionResponse{}, fmt.Errorf("claude-code error: %s", output.Result)
|
||||
}
|
||||
|
||||
// Extract text from result field or content blocks
|
||||
content := output.Result
|
||||
if content == "" && len(output.ContentBlock) > 0 {
|
||||
var parts []string
|
||||
for _, block := range output.ContentBlock {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
parts = append(parts, block.Text)
|
||||
}
|
||||
}
|
||||
content = strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if execErr != nil {
|
||||
finishReason = "error"
|
||||
}
|
||||
|
||||
log.Info("claude_code_response",
|
||||
"content_len", len(content),
|
||||
"input_tokens", output.Usage.InputTokens,
|
||||
"output_tokens", output.Usage.OutputTokens,
|
||||
"num_turns", output.NumTurns,
|
||||
"cost_usd", output.TotalCost,
|
||||
"elapsed_ms", elapsed.Milliseconds(),
|
||||
)
|
||||
|
||||
return coretypes.CompletionResponse{
|
||||
Content: content,
|
||||
Usage: coretypes.TokenUsage{
|
||||
InputTokens: output.Usage.InputTokens,
|
||||
OutputTokens: output.Usage.OutputTokens,
|
||||
TotalTokens: output.Usage.InputTokens + output.Usage.OutputTokens,
|
||||
},
|
||||
FinishReason: finishReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterEnv returns a copy of environ with the named keys removed.
|
||||
func filterEnv(environ []string, keys ...string) []string {
|
||||
out := make([]string, 0, len(environ))
|
||||
for _, e := range environ {
|
||||
skip := false
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(e, k+"=") {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !skip {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user