diff --git a/adk/README.md b/adk/README.md index d1ddb3d6..ebbaa9e5 100644 --- a/adk/README.md +++ b/adk/README.md @@ -15,7 +15,8 @@ This directory provides examples for Eino ADK: - `supervisor`: basic example of supervisor agent. - `layered-supervisor`: another example of supervisor agent, which set a supervisor agent as sub-agent of another supervisor agent. - `integration-project-manager`: another example of using supervisor agent. + - `openclaw-like-agent`: an OpenClaw-style local agent CLI example with workspace awareness, session memory, and skill loading. - `common`: utils. -Additionally, you can enable [coze-loop](https://github.com/coze-dev/coze-loop) trace for examples, see .example.env for keys. \ No newline at end of file +Additionally, you can enable [coze-loop](https://github.com/coze-dev/coze-loop) trace for examples, see .example.env for keys. diff --git a/adk/multiagent/openclaw-like-agent/README.md b/adk/multiagent/openclaw-like-agent/README.md new file mode 100644 index 00000000..7b3e1e6e --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/README.md @@ -0,0 +1,145 @@ +# Eino ADK Multiagent Example: OpenClaw-like Agent + +## Description +`openclaw-like-agent` is a workspace-aware local agent example built on Eino ADK. + +This example is a reproduction of the recent OpenClaw-style local agent experience using Eino ADK. It shows how to build an OpenClaw-inspired agent pattern with Eino primitives, while keeping the project structure consistent with the other examples in this repository. + +Compared with the other `adk/multiagent` demos, this example is closer to a practical local assistant: + +- It starts from a standard `main.go` entry and can be launched directly with `go run`. +- It keeps multi-turn conversation history by `session`. +- It creates and manages a dedicated `workspace`. +- It supports loading skills from multiple directories. +- It exposes filesystem and shell capabilities through a restricted backend. + +The current implementation is organized as a CLI application, with the main logic living under the `myagent` package. + +## Env +Before running it, configure the model-related environment variables: + +```bash +# Required +export OPENAI_API_KEY="" +export OPENAI_MODEL="" + +# Optional +export OPENAI_BASE_URL="" +export OPENAI_BY_AZURE="false" +``` + +## Quick Start +Start interactive chat mode: + +```bash +go run ./adk/multiagent/openclaw-like-agent +``` + +Run one single-turn query: + +```bash +go run ./adk/multiagent/openclaw-like-agent -- run -q "帮我总结当前 workspace 里有哪些文件" +``` + +Use a fixed workspace and session: + +```bash +go run ./adk/multiagent/openclaw-like-agent -- \ + --workspace /tmp/myagent-demo \ + --session-id demo \ + chat +``` + +Example interactive output with sensitive information masked: + +```text +$ go run main.go +MyAgent TUI +workspace: /path/to/eino-examples/adk/multiagent/openclaw-like-agent/myagent_workspace +session: sess_xxxxxxxxxxxxxxxx +commands: /help /session /history /clear /exit + +myagent> 你好 + +[00:15:09] user> 你好 +[run.started] session=sess_xxxxxxxxxxxxxxxx workspace=/path/to/eino-examples/adk/multiagent/openclaw-like-agent/myagent_workspace +[assistant] 你好!很高兴见到你。有什么我可以帮你的吗? + +[run.completed] session=sess_xxxxxxxxxxxxxxxx saved_messages=2 at=00:15:12 + +myagent> +``` + +## CLI Commands +The root command defaults to interactive chat mode, and also provides several subcommands: + +- `chat`: interactive chat mode. +- `run [-q query] [query]`: execute one turn only. +- `session list`: list all sessions in the current workspace. +- `session clear --session-id `: clear conversation history and summary for one session. +- `session delete --session-id `: delete the persisted files for one session. +- `skill list`: list all discovered skills. + +Common flags: + +- `--workspace`, `-w`: workspace directory. Default is `./myagent_workspace`. +- `--session-id`: session id. Auto-generated when omitted. +- `--instruction`: override the default system instruction. +- `--max-iterations`: max loop iterations for the agent. + +## Interactive Commands +In `chat` mode, the following slash commands are supported: + +- `/help` +- `/skills` +- `/session` +- `/history` +- `/clear` +- `/exit` + +## Workspace Layout +When the agent starts for the first time, it will initialize the workspace automatically: + +```text +myagent_workspace/ +├── IDENTITY.md +├── artifacts/ +├── logs/ +├── memory/ +│ └── MEMORY.md +├── sessions/ +└── skills/ +``` + +Key files and directories: + +- `IDENTITY.md`: workspace-level identity override. +- `memory/MEMORY.md`: long-term memory. +- `sessions/`: persisted session metadata and message history. +- `skills/`: workspace-local skills. +- `artifacts/`: files generated during execution. +- `.claude/skills/`: optional skill directory under the workspace. It is supported by the loader, but not created automatically. + +## Skill Loading Order +The agent currently loads skills in the following priority order: + +1. `/skills` +2. `/.claude/skills` +3. `~/.claude/skills` +4. `$XDG_CONFIG_HOME/myagent/skills` or `~/.config/myagent/skills` +5. builtin skills directory, defaulting to `./skills` + +You can inspect discovered skills with: + +```bash +go run ./adk/multiagent/openclaw-like-agent -- skill list +``` + +## Notes +- The backend is workspace-restricted by default and protects key files such as `IDENTITY.md` and `MEMORY.md`. +- Session history is stored as JSONL under `sessions/`. +- This example has already been wired into the repository as a standard sub-application entry and can be built directly with: + +```bash +go build ./adk/multiagent/openclaw-like-agent +``` diff --git a/adk/multiagent/openclaw-like-agent/README_ZH.md b/adk/multiagent/openclaw-like-agent/README_ZH.md new file mode 100644 index 00000000..43fd2982 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/README_ZH.md @@ -0,0 +1,145 @@ +# Eino ADK 多智能体示例:OpenClaw-like Agent + +## 简介 +`openclaw-like-agent` 是一个基于 Eino ADK 构建的、本地运行的 workspace 感知型 Agent 示例。 + +这个示例可以理解为基于 Eino ADK 对近期很火的 OpenClaw 风格本地 Agent 体验做的一次复刻。它展示了如何用 Eino 提供的 Agent、工具、session 和 workspace 能力,搭建出一个 OpenClaw 风格的本地 Agent 形态,同时保持与本仓库其他示例一致的项目结构。 + +和其他 `adk/multiagent` 示例相比,这个例子更接近一个可实际使用的本地助手,也更贴近近期 OpenClaw 风格本地 Agent 的使用方式: + +- 通过标准 `main.go` 入口启动,可直接使用 `go run` 运行。 +- 支持基于 `session` 的多轮对话历史持久化。 +- 自动创建并管理独立的 `workspace`。 +- 支持从多个目录加载 skills。 +- 通过受限 backend 暴露文件系统与 shell 能力。 + +当前实现是一个 CLI 应用,核心逻辑位于 `myagent` 包中。 + +## 环境变量 +运行前请先配置模型相关环境变量: + +```bash +# 必填 +export OPENAI_API_KEY="" +export OPENAI_MODEL="" + +# 可选 +export OPENAI_BASE_URL="" +export OPENAI_BY_AZURE="false" +``` + +## 快速开始 +启动交互式聊天模式: + +```bash +go run ./adk/multiagent/openclaw-like-agent +``` + +执行单轮请求: + +```bash +go run ./adk/multiagent/openclaw-like-agent -- run -q "帮我总结当前 workspace 里有哪些文件" +``` + +指定固定的 workspace 和 session: + +```bash +go run ./adk/multiagent/openclaw-like-agent -- \ + --workspace /tmp/myagent-demo \ + --session-id demo \ + chat +``` + +脱敏后的交互示例: + +```text +$ go run main.go +MyAgent TUI +workspace: /path/to/eino-examples/adk/multiagent/openclaw-like-agent/myagent_workspace +session: sess_xxxxxxxxxxxxxxxx +commands: /help /session /history /clear /exit + +myagent> 你好 + +[00:15:09] user> 你好 +[run.started] session=sess_xxxxxxxxxxxxxxxx workspace=/path/to/eino-examples/adk/multiagent/openclaw-like-agent/myagent_workspace +[assistant] 你好!很高兴见到你。有什么我可以帮你的吗? + +[run.completed] session=sess_xxxxxxxxxxxxxxxx saved_messages=2 at=00:15:12 + +myagent> +``` + +## CLI 命令 +根命令默认进入交互式聊天模式,同时也提供以下子命令: + +- `chat`:进入交互式聊天模式。 +- `run [-q query] [query]`:执行单轮请求。 +- `session list`:列出当前 workspace 下的全部 session。 +- `session clear --session-id `:清空某个 session 的历史和摘要。 +- `session delete --session-id `:删除某个 session 的持久化文件。 +- `skill list`:列出当前可发现的所有 skills。 + +常用参数: + +- `--workspace`, `-w`:指定 workspace 目录,默认是 `./myagent_workspace`。 +- `--session-id`:指定 session id;不传时自动生成。 +- `--instruction`:覆盖默认 system instruction。 +- `--max-iterations`:Agent loop 的最大迭代次数。 + +## 交互模式命令 +在 `chat` 模式下,支持以下 slash commands: + +- `/help` +- `/skills` +- `/session` +- `/history` +- `/clear` +- `/exit` + +## Workspace 目录结构 +首次启动时,Agent 会自动初始化 workspace: + +```text +myagent_workspace/ +├── IDENTITY.md +├── artifacts/ +├── logs/ +├── memory/ +│ └── MEMORY.md +├── sessions/ +└── skills/ +``` + +主要目录和文件说明: + +- `IDENTITY.md`:workspace 级别的 identity 覆盖文件。 +- `memory/MEMORY.md`:长期记忆文件。 +- `sessions/`:session 元数据与消息历史持久化目录。 +- `skills/`:workspace 本地 skills 目录。 +- `artifacts/`:执行过程中产生的产物文件目录。 +- `.claude/skills/`:workspace 下可选的 skill 目录,loader 支持读取,但不会自动创建。 + +## Skill 加载顺序 +当前 Agent 按如下优先级加载 skills: + +1. `/skills` +2. `/.claude/skills` +3. `~/.claude/skills` +4. `$XDG_CONFIG_HOME/myagent/skills` 或 `~/.config/myagent/skills` +5. 内置 skills 目录,默认是 `./skills` + +你可以用下面的命令查看最终发现的 skills: + +```bash +go run ./adk/multiagent/openclaw-like-agent -- skill list +``` + +## 说明 +- backend 默认限制在 workspace 范围内运行,并保护 `IDENTITY.md`、`MEMORY.md` 等关键文件。 +- session 历史会以 JSONL 形式持久化到 `sessions/` 目录。 +- 这个示例已经作为标准子应用接入仓库,可以直接编译: + +```bash +go build ./adk/multiagent/openclaw-like-agent +``` diff --git a/adk/multiagent/openclaw-like-agent/main.go b/adk/multiagent/openclaw-like-agent/main.go new file mode 100644 index 00000000..714d10ad --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/main.go @@ -0,0 +1,35 @@ +/* + * Copyright 2025 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "fmt" + "os" + + "github.com/cloudwego/eino-examples/adk/multiagent/openclaw-like-agent/myagent" +) + +func main() { + cmd := myagent.RunMyagentCmd() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/backend.go b/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/backend.go new file mode 100644 index 00000000..b4e08ff9 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/backend.go @@ -0,0 +1,945 @@ +// Package secureBackend implements filesystem.Backend and filesystem.StreamingShell +// backed by the workspace-aware fileSystem abstraction from the tools package. +// +// Key properties: +// - All file paths are sanitized (bare newlines stripped) before use. +// - When Restrict=true the backend is confined to Workspace via os.Root (sandboxFs). +// - Extra read-only paths outside Workspace may be whitelisted via AllowPaths regexps. +// - GrepRaw delegates to the system `rg` binary (ripgrep must be in PATH). +// - Execute / ExecuteStreaming run commands through /bin/sh; a ValidateCommand hook +// lets callers deny dangerous commands before execution. +package secureBackend + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime/debug" + "sort" + "strings" + "time" + + "github.com/bmatcuk/doublestar/v4" + "github.com/cloudwego/eino-examples/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/fileutil" + "github.com/cloudwego/eino/adk/filesystem" + "github.com/cloudwego/eino/schema" +) + +// ───────────────────────────────────────────── +// Config +// ───────────────────────────────────────────── + +// Config holds construction parameters for SecureBackend. +type Config struct { + // Workspace is the root directory the backend is allowed to operate in. + // Required. + Workspace string + + // Restrict confines all file operations to Workspace when true. + // Absolute paths outside Workspace are rejected unless they match AllowPaths. + Restrict bool + + // AllowPaths is an optional list of compiled regexp patterns for paths outside + // Workspace that are still permitted (e.g. shared read-only config dirs). + // Ignored when Restrict=false. + AllowPaths []*regexp.Regexp + + // ValidateCommand is an optional hook called before every shell command. + // Return a non-nil error to reject the command. + ValidateCommand func(cmd string) error + + // MaxReadLines caps the number of lines returned by Read. + // Defaults to 2000 when ≤ 0. + MaxReadLines int + + // ProtectedPaths is a list of absolute paths that must not be written or + // edited via the backend. Any Write/Edit targeting one of these paths will + // be rejected with an "access denied: protected path" error. Use this to + // guard files like MEMORY.md and IDENTITY.md that the agent should only + // mutate through dedicated store APIs. + ProtectedPaths []string +} + +// ───────────────────────────────────────────── +// SecureBackend +// ───────────────────────────────────────────── + +// SecureBackend implements filesystem.Backend and filesystem.StreamingShell. +type SecureBackend struct { + workspace string + fs fileSystem + allowPaths []*regexp.Regexp + validateCommand func(string) error + maxReadLines int + protectedPaths map[string]struct{} +} + +// New creates a SecureBackend from cfg. +func New(cfg *Config) (*SecureBackend, error) { + if cfg == nil { + return nil, errors.New("secureBackend: config is required") + } + + ws := strings.TrimSpace(cfg.Workspace) + if ws == "" { + return nil, errors.New("secureBackend: Workspace must not be empty") + } + + absWs, err := filepath.Abs(ws) + if err != nil { + return nil, fmt.Errorf("secureBackend: cannot resolve Workspace: %w", err) + } + + validateCmd := cfg.ValidateCommand + if validateCmd == nil { + validateCmd = func(string) error { return nil } + } + + maxLines := cfg.MaxReadLines + if maxLines <= 0 { + maxLines = 2000 + } + + protected := make(map[string]struct{}, len(cfg.ProtectedPaths)) + for _, p := range cfg.ProtectedPaths { + if abs, err := filepath.Abs(p); err == nil { + protected[abs] = struct{}{} + } + } + + return &SecureBackend{ + workspace: absWs, + fs: buildFs(absWs, cfg.Restrict, cfg.AllowPaths), + allowPaths: cfg.AllowPaths, + validateCommand: validateCmd, + maxReadLines: maxLines, + protectedPaths: protected, + }, nil +} + +// Workspace returns the absolute workspace root. +func (b *SecureBackend) Workspace() string { return b.workspace } + +// isProtected reports whether the resolved absolute path is in the protected +// paths set and must not be written or edited. +func (b *SecureBackend) isProtected(resolvedAbs string) bool { + if len(b.protectedPaths) == 0 { + return false + } + _, ok := b.protectedPaths[filepath.Clean(resolvedAbs)] + return ok +} + +// ───────────────────────────────────────────── +// filesystem.Backend – LsInfo +// ───────────────────────────────────────────── + +func (b *SecureBackend) LsInfo(_ context.Context, req *filesystem.LsInfoRequest) ([]filesystem.FileInfo, error) { + path := sanitizePath(req.Path) + if path == "" { + path = b.workspace + } + + resolved, err := b.resolvePath(path) + if err != nil { + return nil, err + } + + entries, err := b.fs.ReadDir(resolved) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("ls: %w", err) + } + + files := make([]filesystem.FileInfo, 0, len(entries)) + for _, e := range entries { + fi := filesystem.FileInfo{ + Path: e.Name(), + IsDir: e.IsDir(), + } + if info, err2 := e.Info(); err2 == nil { + fi.Size = info.Size() + fi.ModifiedAt = info.ModTime().UTC().Format(time.RFC3339) + } + files = append(files, fi) + } + return files, nil +} + +// ───────────────────────────────────────────── +// filesystem.Backend – Read +// ───────────────────────────────────────────── + +func (b *SecureBackend) Read(_ context.Context, req *filesystem.ReadRequest) (*filesystem.FileContent, error) { + path := sanitizePath(req.FilePath) + resolved, err := b.resolvePath(path) + if err != nil { + return nil, err + } + + f, err := b.fs.Open(resolved) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + defer f.Close() + + offset := req.Offset + if offset <= 0 { + offset = 1 + } + limit := req.Limit + if limit <= 0 || limit > b.maxReadLines { + limit = b.maxReadLines + } + + reader := bufio.NewReader(f) + var sb strings.Builder + lineNum := 1 + linesRead := 0 + + for { + line, err := reader.ReadString('\n') + if line != "" { + if lineNum >= offset { + sb.WriteString(line) + linesRead++ + if linesRead >= limit { + break + } + } + lineNum++ + } + if err != nil { + if err != io.EOF { + return nil, fmt.Errorf("read: error reading file: %w", err) + } + break + } + } + + return &filesystem.FileContent{Content: strings.TrimSuffix(sb.String(), "\n")}, nil +} + +// ───────────────────────────────────────────── +// filesystem.Backend – GrepRaw (delegates to rg) +// ───────────────────────────────────────────── + +type rgJSON struct { + Type string `json:"type"` + Data struct { + Path struct { + Text string `json:"text"` + } `json:"path"` + LineNumber int `json:"line_number"` + Lines struct { + Text string `json:"text"` + } `json:"lines"` + } `json:"data"` +} + +func (b *SecureBackend) GrepRaw(ctx context.Context, req *filesystem.GrepRequest) ([]filesystem.GrepMatch, error) { + if req.Pattern == "" { + return nil, fmt.Errorf("grep: pattern is required") + } + + searchPath := sanitizePath(req.Path) + if searchPath == "" { + searchPath = b.workspace + } else { + var err error + searchPath, err = b.resolvePath(searchPath) + if err != nil { + return nil, fmt.Errorf("grep: %w", err) + } + } + + args := []string{"--json"} + if req.CaseInsensitive { + args = append(args, "-i") + } + if req.EnableMultiline { + args = append(args, "-U", "--multiline-dotall") + } + if req.FileType != "" { + args = append(args, "--type", req.FileType) + } else if req.Glob != "" { + args = append(args, "--glob", req.Glob) + } + if req.AfterLines > 0 { + args = append(args, "-A", fmt.Sprintf("%d", req.AfterLines)) + } + if req.BeforeLines > 0 { + args = append(args, "-B", fmt.Sprintf("%d", req.BeforeLines)) + } + args = append(args, "-e", req.Pattern, "--", searchPath) + + cmd := exec.CommandContext(ctx, "rg", args...) + output, err := cmd.Output() + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return nil, fmt.Errorf("grep: ripgrep (rg) is not installed or not in PATH") + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return []filesystem.GrepMatch{}, nil + } + return nil, fmt.Errorf("grep: ripgrep failed: %w", err) + } + + var matches []filesystem.GrepMatch + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + var row rgJSON + if jsonErr := json.Unmarshal([]byte(line), &row); jsonErr != nil { + continue + } + if row.Type == "match" || row.Type == "context" { + matches = append(matches, filesystem.GrepMatch{ + Path: row.Data.Path.Text, + Line: row.Data.LineNumber, + Content: strings.TrimRight(row.Data.Lines.Text, "\n"), + }) + } + } + return matches, nil +} + +// ───────────────────────────────────────────── +// filesystem.Backend – GlobInfo +// ───────────────────────────────────────────── + +func (b *SecureBackend) GlobInfo(ctx context.Context, req *filesystem.GlobInfoRequest) ([]filesystem.FileInfo, error) { + basePath := sanitizePath(req.Path) + if basePath == "" { + basePath = b.workspace + } else { + var err error + basePath, err = b.resolvePath(basePath) + if err != nil { + return nil, fmt.Errorf("glob: %w", err) + } + } + + var relPaths []string + err := filepath.WalkDir(basePath, func(p string, d fs.DirEntry, err error) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if err != nil { + if os.IsPermission(err) { + return filepath.SkipDir + } + return err + } + rel, relErr := filepath.Rel(basePath, p) + if relErr != nil { + return nil + } + rel = filepath.ToSlash(rel) + if rel == "." { + return nil + } + matched, _ := doublestar.Match(req.Pattern, rel) + if matched { + relPaths = append(relPaths, rel) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("glob: walk failed: %w", err) + } + + sort.Strings(relPaths) + + files := make([]filesystem.FileInfo, 0, len(relPaths)) + for _, rel := range relPaths { + files = append(files, filesystem.FileInfo{Path: rel}) + } + return files, nil +} + +// ───────────────────────────────────────────── +// filesystem.Backend – Write +// ───────────────────────────────────────────── + +func (b *SecureBackend) Write(_ context.Context, req *filesystem.WriteRequest) error { + path := sanitizePath(req.FilePath) + resolved, err := b.resolveWritePath(path) + if err != nil { + return fmt.Errorf("write: %w", err) + } + if b.isProtected(resolved) { + return fmt.Errorf("write: access denied: %s is a protected path and cannot be overwritten directly", req.FilePath) + } + + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { + return fmt.Errorf("write: cannot create parent directories: %w", err) + } + + return fileutil.WriteFileAtomic(resolved, []byte(req.Content), 0o644) +} + +// ───────────────────────────────────────────── +// filesystem.Backend – Edit +// ───────────────────────────────────────────── + +func (b *SecureBackend) Edit(_ context.Context, req *filesystem.EditRequest) error { + if req.OldString == "" { + return fmt.Errorf("edit: OldString must not be empty") + } + if req.OldString == req.NewString { + return fmt.Errorf("edit: NewString must differ from OldString") + } + + path := sanitizePath(req.FilePath) + resolved, err := b.resolveWritePath(path) + if err != nil { + return fmt.Errorf("edit: %w", err) + } + if b.isProtected(resolved) { + return fmt.Errorf("edit: access denied: %s is a protected path and cannot be edited directly", req.FilePath) + } + + raw, err := b.fs.ReadFile(resolved) + if err != nil { + return fmt.Errorf("edit: %w", err) + } + + text := string(raw) + count := strings.Count(text, req.OldString) + if count == 0 { + return fmt.Errorf("edit: OldString not found in file") + } + if count > 1 && !req.ReplaceAll { + return fmt.Errorf("edit: OldString appears %d times; set ReplaceAll=true to replace all", count) + } + + var newText string + if req.ReplaceAll { + newText = strings.ReplaceAll(text, req.OldString, req.NewString) + } else { + newText = strings.Replace(text, req.OldString, req.NewString, 1) + } + + return fileutil.WriteFileAtomic(resolved, []byte(newText), 0o644) +} + +// ───────────────────────────────────────────── +// filesystem.StreamingShell – ExecuteStreaming +// ───────────────────────────────────────────── + +func (b *SecureBackend) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) { + if input.Command == "" { + return nil, fmt.Errorf("execute: command is required") + } + if err := b.validateCommand(input.Command); err != nil { + return nil, fmt.Errorf("execute: command rejected: %w", err) + } + + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", input.Command) + cmd.Dir = b.workspace + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("execute: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + _ = stdout.Close() + return nil, fmt.Errorf("execute: stderr pipe: %w", err) + } + + sr, w := schema.Pipe[*filesystem.ExecuteResponse](100) + + if err := cmd.Start(); err != nil { + _ = stdout.Close() + _ = stderr.Close() + go func() { defer w.Close(); w.Send(nil, fmt.Errorf("execute: failed to start: %w", err)) }() + return sr, nil + } + + if input.RunInBackendGround { + go func() { + defer func() { + if r := recover(); r != nil { + _ = cmd.Process.Kill() + } + _ = stdout.Close() + _ = stderr.Close() + }() + done := make(chan struct{}) + go func() { + drainConcurrently(stdout, stderr) + _ = cmd.Wait() + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + _ = cmd.Process.Kill() + } + }() + go func() { + defer w.Close() + w.Send(&filesystem.ExecuteResponse{Output: "command started in background\n", ExitCode: new(int)}, nil) + }() + return sr, nil + } + + go streamOutput(ctx, cmd, stdout, stderr, w) + return sr, nil +} + +// Execute runs a command synchronously and returns its output. +func (b *SecureBackend) Execute(ctx context.Context, input *filesystem.ExecuteRequest) (*filesystem.ExecuteResponse, error) { + if input.Command == "" { + return nil, fmt.Errorf("execute: command is required") + } + if err := b.validateCommand(input.Command); err != nil { + return nil, fmt.Errorf("execute: command rejected: %w", err) + } + + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", input.Command) + cmd.Dir = b.workspace + + var outBuf, errBuf strings.Builder + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + + exitCode := 0 + if runErr := cmd.Run(); runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + exitCode = exitErr.ExitCode() + parts := []string{fmt.Sprintf("command exited with non-zero code %d", exitCode)} + if s := outBuf.String(); s != "" { + parts = append(parts, "[stdout]:\n"+s) + } + if s := errBuf.String(); s != "" { + parts = append(parts, "[stderr]:\n"+s) + } + return &filesystem.ExecuteResponse{Output: strings.Join(parts, "\n"), ExitCode: &exitCode}, nil + } + return nil, fmt.Errorf("execute: %w", runErr) + } + + return &filesystem.ExecuteResponse{Output: outBuf.String(), ExitCode: &exitCode}, nil +} + +// ───────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────── + +// resolvePath converts a raw path (absolute or relative) to a clean absolute +// path and, when the backend is in restricted mode, verifies it stays within +// the workspace (or is whitelisted). +// +// Path sanitization (newlines, surrounding whitespace) is the caller's +// responsibility and must be done before calling this method. +func (b *SecureBackend) resolvePath(path string) (string, error) { + return b.resolvePathForRead(path) +} + +func (b *SecureBackend) resolvePathForRead(path string) (string, error) { + var abs string + if filepath.IsAbs(path) { + abs = filepath.Clean(path) + } else { + abs = filepath.Clean(filepath.Join(b.workspace, path)) + } + if b.isWithinWorkspace(abs) || b.matchesAllowPath(abs) { + return abs, nil + } + return "", fmt.Errorf("access denied: path escapes workspace: %s", abs) +} + +func (b *SecureBackend) resolveWritePath(path string) (string, error) { + var abs string + if filepath.IsAbs(path) { + abs = filepath.Clean(path) + } else { + abs = filepath.Clean(filepath.Join(b.workspace, path)) + } + if b.isWithinWorkspace(abs) { + return abs, nil + } + return "", fmt.Errorf("access denied: path escapes workspace: %s", abs) +} + +func (b *SecureBackend) isWithinWorkspace(abs string) bool { + rel, err := filepath.Rel(b.workspace, abs) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func (b *SecureBackend) matchesAllowPath(abs string) bool { + if len(b.allowPaths) == 0 { + return false + } + abs = filepath.Clean(abs) + for _, pattern := range b.allowPaths { + if pattern.MatchString(abs) { + return true + } + } + return false +} + +// sanitizePath removes embedded newlines and surrounding whitespace from a +// path string so that LLM-generated paths with bare \n do not cause failures. +func sanitizePath(path string) string { + path = strings.ReplaceAll(path, "\r\n", "") + path = strings.ReplaceAll(path, "\r", "") + path = strings.ReplaceAll(path, "\n", "") + return strings.TrimSpace(path) +} + +// ───────────────────────────────────────────── +// fileSystem abstraction (mirrors tools package) +// ───────────────────────────────────────────── + +// fileSystem is the minimal interface SecureBackend needs for file I/O. +// It intentionally mirrors the private interface in tools/filesystem.go so both +// packages can evolve independently while sharing the same design pattern. +type fileSystem interface { + ReadFile(path string) ([]byte, error) + WriteFile(path string, data []byte) error + ReadDir(path string) ([]os.DirEntry, error) + Open(path string) (fs.File, error) +} + +func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { + if !restrict { + return &hostFs{} + } + sb := &sandboxFs{workspace: workspace} + if len(patterns) > 0 { + return &whitelistFs{sandbox: sb, patterns: patterns} + } + return sb +} + +// ── hostFs ────────────────────────────────── + +type hostFs struct{} + +func (h *hostFs) ReadFile(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %w", err) + } + if os.IsPermission(err) { + return nil, fmt.Errorf("access denied: %w", err) + } + return nil, err + } + return data, nil +} + +func (h *hostFs) WriteFile(path string, data []byte) error { + return fileutil.WriteFileAtomic(path, data, 0o644) +} + +func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { + return os.ReadDir(path) +} + +func (h *hostFs) Open(path string) (fs.File, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %w", err) + } + if os.IsPermission(err) { + return nil, fmt.Errorf("access denied: %w", err) + } + return nil, err + } + return f, nil +} + +// ── sandboxFs ─────────────────────────────── + +// sandboxFs confines all operations to workspace using os.Root. +type sandboxFs struct { + workspace string +} + +func (s *sandboxFs) safeRelPath(path string) (string, error) { + rel := filepath.Clean(path) + if filepath.IsAbs(rel) { + var err error + rel, err = filepath.Rel(s.workspace, rel) + if err != nil { + return "", fmt.Errorf("cannot relativize path: %w", err) + } + } + if !filepath.IsLocal(rel) { + return "", fmt.Errorf("access denied: path escapes workspace: %s", path) + } + return rel, nil +} + +func (s *sandboxFs) withRoot(path string, fn func(*os.Root, string) error) error { + rel, err := s.safeRelPath(path) + if err != nil { + return err + } + root, err := os.OpenRoot(s.workspace) + if err != nil { + return fmt.Errorf("cannot open workspace root: %w", err) + } + defer root.Close() + return fn(root, rel) +} + +func (s *sandboxFs) ReadFile(path string) ([]byte, error) { + var data []byte + err := s.withRoot(path, func(root *os.Root, rel string) error { + f, err := root.Open(rel) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("file not found: %w", err) + } + return fmt.Errorf("access denied: %w", err) + } + defer f.Close() + data, err = io.ReadAll(f) + return err + }) + return data, err +} + +func (s *sandboxFs) WriteFile(path string, data []byte) error { + rel, err := s.safeRelPath(path) + if err != nil { + return err + } + absTarget := filepath.Join(s.workspace, rel) + + if dir := filepath.Dir(absTarget); dir != s.workspace { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("cannot create directories: %w", err) + } + } + + tmp := absTarget + fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("cannot create temp file: %w", err) + } + if _, err = f.Write(data); err != nil { + f.Close() + _ = os.Remove(tmp) + return err + } + if err = f.Sync(); err != nil { + f.Close() + _ = os.Remove(tmp) + return err + } + if err = f.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + if err = os.Rename(tmp, absTarget); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func (s *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { + var entries []os.DirEntry + err := s.withRoot(path, func(root *os.Root, rel string) error { + var err error + entries, err = fs.ReadDir(root.FS(), rel) + return err + }) + return entries, err +} + +func (s *sandboxFs) Open(path string) (fs.File, error) { + rel, err := s.safeRelPath(path) + if err != nil { + return nil, err + } + root, err := os.OpenRoot(s.workspace) + if err != nil { + return nil, fmt.Errorf("cannot open workspace root: %w", err) + } + // root.Close() is intentionally deferred to the caller via the returned file. + // We wrap the file so that closing it also closes the root handle. + f, err := root.Open(rel) + if err != nil { + root.Close() + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %w", err) + } + return nil, fmt.Errorf("access denied: %w", err) + } + return &rootedFile{File: f, root: root}, nil +} + +// rootedFile wraps fs.File and closes the associated os.Root on Close. +type rootedFile struct { + fs.File + root *os.Root +} + +func (r *rootedFile) Close() error { + err := r.File.Close() + _ = r.root.Close() + return err +} + +// ── whitelistFs ───────────────────────────── + +// whitelistFs extends sandboxFs with an allowlist of paths outside workspace. +type whitelistFs struct { + sandbox *sandboxFs + patterns []*regexp.Regexp +} + +func (w *whitelistFs) matches(path string) bool { + abs := filepath.Clean(path) + if !filepath.IsAbs(abs) { + return false + } + for _, p := range w.patterns { + if p.MatchString(abs) { + return true + } + } + return false +} + +func (w *whitelistFs) ReadFile(path string) ([]byte, error) { + if w.matches(path) { + return (&hostFs{}).ReadFile(path) + } + return w.sandbox.ReadFile(path) +} + +func (w *whitelistFs) WriteFile(path string, data []byte) error { + if w.matches(path) { + return fmt.Errorf("access denied: write outside workspace is not allowed: %s", path) + } + return w.sandbox.WriteFile(path, data) +} + +func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) { + if w.matches(path) { + return (&hostFs{}).ReadDir(path) + } + return w.sandbox.ReadDir(path) +} + +func (w *whitelistFs) Open(path string) (fs.File, error) { + if w.matches(path) { + return (&hostFs{}).Open(path) + } + return w.sandbox.Open(path) +} + +// ───────────────────────────────────────────── +// Shell streaming helpers +// ───────────────────────────────────────────── + +func streamOutput(ctx context.Context, cmd *exec.Cmd, stdout, stderr io.ReadCloser, w *schema.StreamWriter[*filesystem.ExecuteResponse]) { + defer func() { + if r := recover(); r != nil { + w.Send(nil, &panicErr{info: r, stack: debug.Stack()}) + return + } + w.Close() + }() + + stderrBytes := make(chan []byte, 1) + stderrErr := make(chan error, 1) + go func() { + data, err := io.ReadAll(stderr) + stderrBytes <- data + stderrErr <- err + }() + + reader := bufio.NewReader(stdout) + hasOutput := false + for { + line, err := reader.ReadString('\n') + if line != "" { + hasOutput = true + select { + case <-ctx.Done(): + _ = cmd.Process.Kill() + w.Send(nil, ctx.Err()) + return + default: + w.Send(&filesystem.ExecuteResponse{Output: line}, nil) + } + } + if err != nil { + if err != io.EOF { + w.Send(nil, fmt.Errorf("execute: read stdout: %w", err)) + return + } + break + } + } + + if err := <-stderrErr; err != nil { + w.Send(nil, fmt.Errorf("execute: read stderr: %w", err)) + return + } + errData := <-stderrBytes + + if err := cmd.Wait(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + code := exitErr.ExitCode() + parts := []string{fmt.Sprintf("command exited with non-zero code %d", code)} + if s := strings.TrimSpace(string(errData)); s != "" { + parts = append(parts, "[stderr]:\n"+s) + } + w.Send(&filesystem.ExecuteResponse{Output: strings.Join(parts, "\n"), ExitCode: &code}, nil) + return + } + w.Send(nil, fmt.Errorf("execute: command failed: %w", err)) + return + } + + if !hasOutput { + w.Send(&filesystem.ExecuteResponse{ExitCode: new(int)}, nil) + } +} + +func drainConcurrently(a, b io.Reader) { + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(io.Discard, a); done <- struct{}{} }() + go func() { _, _ = io.Copy(io.Discard, b); done <- struct{}{} }() + <-done + <-done +} + +type panicErr struct { + info any + stack []byte +} + +func (p *panicErr) Error() string { + return fmt.Sprintf("panic: %v\nstack:\n%s", p.info, p.stack) +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/backend_test.go b/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/backend_test.go new file mode 100644 index 00000000..0b83c5de --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/backend_test.go @@ -0,0 +1,56 @@ +package secureBackend + +import ( + "context" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/cloudwego/eino/adk/filesystem" +) + +func TestAllowPathsPermitReadButNotWriteOutsideWorkspace(t *testing.T) { + workspace := t.TempDir() + externalSkillsDir := filepath.Join(t.TempDir(), "skills") + skillFile := filepath.Join(externalSkillsDir, "demo", "SKILL.md") + + if err := os.MkdirAll(filepath.Dir(skillFile), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := os.WriteFile(skillFile, []byte("# demo\n"), 0o644); err != nil { + t.Fatalf("write skill file failed: %v", err) + } + + sep := regexp.QuoteMeta(string(filepath.Separator)) + allowPattern := regexp.MustCompile("^" + regexp.QuoteMeta(filepath.Clean(externalSkillsDir)) + "(?:$|" + sep + ")") + + backend, err := New(&Config{ + Workspace: workspace, + Restrict: true, + AllowPaths: []*regexp.Regexp{allowPattern}, + }) + if err != nil { + t.Fatalf("new backend failed: %v", err) + } + + content, err := backend.Read(context.Background(), &filesystem.ReadRequest{FilePath: skillFile}) + if err != nil { + t.Fatalf("read allowlisted skill failed: %v", err) + } + if !strings.Contains(content.Content, "# demo") { + t.Fatalf("unexpected read content: %q", content.Content) + } + + err = backend.Write(context.Background(), &filesystem.WriteRequest{ + FilePath: skillFile, + Content: "mutated", + }) + if err == nil { + t.Fatal("expected write outside workspace to be denied") + } + if !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("unexpected write error: %v", err) + } +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/fileutil/file.go b/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/fileutil/file.go new file mode 100644 index 00000000..7ca87237 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend/fileutil/file.go @@ -0,0 +1,119 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package fileutil provides file manipulation utilities. +package fileutil + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern. +// +// This guarantees that the target file is either: +// - Completely written with the new data +// - Unchanged (if any step fails before rename) +// +// The function: +// 1. Creates a temp file in the same directory (original untouched) +// 2. Writes data to temp file +// 3. Syncs data to disk (critical for SD cards/flash storage) +// 4. Sets file permissions +// 5. Syncs directory metadata (ensures rename is durable) +// 6. Atomically renames temp file to target path +// +// Safety guarantees: +// - Original file is NEVER modified until successful rename +// - Temp file is always cleaned up on error +// - Data is flushed to physical storage before rename +// - Directory entry is synced to prevent orphaned inodes +// +// Parameters: +// - path: Target file path +// - data: Data to write +// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable) +// +// Returns: +// - Error if any step fails, nil on success +// +// Example: +// +// // Secure config file (owner read/write only) +// err := utils.WriteFileAtomic("config.json", data, 0o600) +// +// // Public readable file +// err := utils.WriteFileAtomic("public.txt", data, 0o644) +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Create temp file in the same directory (ensures atomic rename works) + // Using a hidden prefix (.tmp-) to avoid issues with some tools + tmpFile, err := os.OpenFile( + filepath.Join(dir, fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())), + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + perm, + ) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + + tmpPath := tmpFile.Name() + cleanup := true + + defer func() { + if cleanup { + tmpFile.Close() + _ = os.Remove(tmpPath) + } + }() + + // Write data to temp file + // Note: Original file is untouched at this point + if _, err := tmpFile.Write(data); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + // CRITICAL: Force sync to storage medium before any other operations. + // This ensures data is physically written to disk, not just cached. + // Essential for SD cards, eMMC, and other flash storage on edge devices. + if err := tmpFile.Sync(); err != nil { + return fmt.Errorf("failed to sync temp file: %w", err) + } + + // Set file permissions before closing + if err := tmpFile.Chmod(perm); err != nil { + return fmt.Errorf("failed to set permissions: %w", err) + } + + // Close file before rename (required on Windows) + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // Atomic rename: temp file becomes the target + // On POSIX: rename() is atomic + // On Windows: Rename() is atomic for files + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("failed to rename temp file: %w", err) + } + + // Sync directory to ensure rename is durable + // This prevents the renamed file from disappearing after a crash + if dirFile, err := os.Open(dir); err == nil { + _ = dirFile.Sync() + dirFile.Close() + } + + // Success: skip cleanup (file was renamed, no temp to remove) + cleanup = false + return nil +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/context_builder.go b/adk/multiagent/openclaw-like-agent/myagent/context_builder.go new file mode 100644 index 00000000..d4fd8021 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/context_builder.go @@ -0,0 +1,200 @@ +package myagent + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +type ContextBuilder struct { + workspace string + skillsLoader *SkillsLoader + memory *MemoryStore + identity *IdentityStore +} + +type SkillsLoader struct { + workspaceSkillsDir string + dotClaudeWorkspaceSkillsDir string + dotClaudeHomeSkillsDir string + globalSkillsDir string + builtinSkillsDir string +} + +type MemoryStore struct { + workspace string +} + +func NewContextBuilder(workspace string) *ContextBuilder { + builtinSkillsDir := strings.TrimSpace(os.Getenv("BUILTIN_SKILLS")) + if builtinSkillsDir == "" { + wd, _ := os.Getwd() + builtinSkillsDir = filepath.Join(wd, "skills") + } + globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") + + homeDir, _ := os.UserHomeDir() + + return &ContextBuilder{ + workspace: workspace, + skillsLoader: NewSkillsLoader( + workspace, + homeDir, + globalSkillsDir, + builtinSkillsDir, + ), + memory: NewMemoryStore(workspace), + identity: NewIdentityStore(workspace), + } +} + +func NewSkillsLoader(workspace, homeDir, globalSkillsDir, builtinSkillsDir string) *SkillsLoader { + return &SkillsLoader{ + workspaceSkillsDir: filepath.Join(workspace, "skills"), + dotClaudeWorkspaceSkillsDir: filepath.Join(workspace, ".claude", "skills"), + dotClaudeHomeSkillsDir: filepath.Join(homeDir, ".claude", "skills"), + globalSkillsDir: globalSkillsDir, + builtinSkillsDir: builtinSkillsDir, + } +} + +func NewMemoryStore(workspace string) *MemoryStore { + return &MemoryStore{workspace: workspace} +} + +func (cb *ContextBuilder) BuildInstruction(sessionID, override string) (string, error) { + if strings.TrimSpace(override) != "" { + return override, nil + } + + agentsContent, err := cb.getIdentity() + if err != nil { + return "", err + } + + identityContent, err := cb.identity.Read() + if err != nil { + return "", err + } + memoryContent, err := readOptionalFile(cb.memory.MemoryFile()) + if err != nil { + return "", err + } + workspaceSkills, err := collectSkillSummary(cb.skillsLoader.workspaceSkillsDir) + if err != nil { + return "", err + } + dotClaudeWorkspaceSkills, err := collectSkillSummary(cb.skillsLoader.dotClaudeWorkspaceSkillsDir) + if err != nil && !os.IsNotExist(err) { + return "", err + } + dotClaudeHomeSkills, err := collectSkillSummary(cb.skillsLoader.dotClaudeHomeSkillsDir) + if err != nil && !os.IsNotExist(err) { + return "", err + } + globalSkills, err := collectSkillSummary(cb.skillsLoader.globalSkillsDir) + if err != nil && !os.IsNotExist(err) { + return "", err + } + builtinSkills, err := collectSkillSummary(cb.skillsLoader.builtinSkillsDir) + if err != nil && !os.IsNotExist(err) { + return "", err + } + + var sb strings.Builder + sb.WriteString(strings.TrimSpace(agentsContent)) + sb.WriteString("\n\n## Runtime Context\n") + sb.WriteString("Current time: ") + sb.WriteString(nowRFC3339()) + sb.WriteString("\nSession ID: ") + sb.WriteString(sessionID) + sb.WriteString("\n") + + if strings.TrimSpace(identityContent) != "" { + sb.WriteString("\n## Identity Override\n") + sb.WriteString(trimForPrompt(identityContent, 2400)) + sb.WriteString("\n") + } + if strings.TrimSpace(memoryContent) != "" { + sb.WriteString("\n## Memory\n") + sb.WriteString(trimForPrompt(memoryContent, 2400)) + sb.WriteString("\n") + } + + appendSkillsSection(&sb, "Workspace Skills", workspaceSkills) + appendSkillsSection(&sb, "Workspace .claude Skills", dotClaudeWorkspaceSkills) + appendSkillsSection(&sb, "Home .claude Skills", dotClaudeHomeSkills) + appendSkillsSection(&sb, "Global Skills", globalSkills) + appendSkillsSection(&sb, "Builtin Skills", builtinSkills) + + return sb.String(), nil +} + +func (cb *ContextBuilder) getIdentity() (string, error) { + tmpl, err := template.New("identity").Parse(`minichat + +You are minichat, a helpful AI assistant. + +## Workspace +Your workspace is at: {{.Workspace}} +- Memory: {{.Workspace}}/memory/MEMORY.md +- Daily Notes: {{.Workspace}}/memory/YYYYMM/YYYYMMDD.md +- Skills: {{.Workspace}}/skills/{skill-name}/SKILL.md +- Identity: {{.Workspace}}/IDENTITY.md + +## Important Rules + +1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. + +2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. + +3. **Memory** - When interacting with me if something seems memorable, update {{.Workspace}}/memory/MEMORY.md + +4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content. +`) + if err != nil { + return "", fmt.Errorf("解析 identity 模板失败: %w", err) + } + + data := struct { + Workspace string + }{ + Workspace: cb.workspace, + } + + var sb strings.Builder + if err := tmpl.Execute(&sb, data); err != nil { + return "", fmt.Errorf("渲染 identity 模板失败: %w", err) + } + sbStr := sb.String() + return sbStr, nil +} + +func (m *MemoryStore) MemoryFile() string { + return filepath.Join(m.workspace, "memory", memoryFileName) +} + +func getGlobalConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil || strings.TrimSpace(configDir) == "" { + homeDir, homeErr := os.UserHomeDir() + if homeErr != nil { + return ".myagent" + } + return filepath.Join(homeDir, ".config", "myagent") + } + return filepath.Join(configDir, "myagent") +} + +func appendSkillsSection(sb *strings.Builder, title, content string) { + if strings.TrimSpace(content) == "" { + return + } + sb.WriteString("\n## ") + sb.WriteString(title) + sb.WriteString("\n") + sb.WriteString(content) + sb.WriteString("\n") +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/context_store.go b/adk/multiagent/openclaw-like-agent/myagent/context_store.go new file mode 100644 index 00000000..700e7fe6 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/context_store.go @@ -0,0 +1,69 @@ +package myagent + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type IdentityStore struct { + workspace string +} + +func NewIdentityStore(workspace string) *IdentityStore { + return &IdentityStore{workspace: workspace} +} + +func (m *MemoryStore) Read() (string, error) { + return readOptionalFile(m.MemoryFile()) +} + +func (m *MemoryStore) Write(content string) error { + path := m.MemoryFile() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("创建 memory 目录失败: %w", err) + } + if err := os.WriteFile(path, []byte(strings.TrimSpace(content)+"\n"), 0o644); err != nil { + return fmt.Errorf("写入 MEMORY.md 失败: %w", err) + } + return nil +} + +func (i *IdentityStore) IdentityFile() string { + return filepath.Join(i.workspace, identityFileName) +} + +func (i *IdentityStore) Read() (string, error) { + return readOptionalFile(i.IdentityFile()) +} + +func (i *IdentityStore) Write(content string) error { + path := i.IdentityFile() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("创建 identity 目录失败: %w", err) + } + if err := os.WriteFile(path, []byte(strings.TrimSpace(content)+"\n"), 0o644); err != nil { + return fmt.Errorf("写入 IDENTITY.md 失败: %w", err) + } + return nil +} + +func isProtectedContextPath(workspaceRoot, target string) bool { + absPath := target + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(workspaceRoot, target) + } + absPath = filepath.Clean(absPath) + + protected := []string{ + filepath.Join(workspaceRoot, "memory", memoryFileName), + filepath.Join(workspaceRoot, identityFileName), + } + for _, path := range protected { + if absPath == filepath.Clean(path) { + return true + } + } + return false +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/context_store_test.go b/adk/multiagent/openclaw-like-agent/myagent/context_store_test.go new file mode 100644 index 00000000..cf805a59 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/context_store_test.go @@ -0,0 +1,74 @@ +package myagent + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestMemoryAndIdentityStoreReadWrite(t *testing.T) { + workspace := t.TempDir() + + memoryStore := NewMemoryStore(workspace) + identityStore := NewIdentityStore(workspace) + + if err := memoryStore.Write("prefers concise Chinese replies"); err != nil { + t.Fatalf("memory write failed: %v", err) + } + if err := identityStore.Write("You are minichat."); err != nil { + t.Fatalf("identity write failed: %v", err) + } + + memoryContent, err := memoryStore.Read() + if err != nil { + t.Fatalf("memory read failed: %v", err) + } + if !strings.Contains(memoryContent, "prefers concise Chinese replies") { + t.Fatalf("unexpected memory content: %q", memoryContent) + } + + identityContent, err := identityStore.Read() + if err != nil { + t.Fatalf("identity read failed: %v", err) + } + if !strings.Contains(identityContent, "You are minichat.") { + t.Fatalf("unexpected identity content: %q", identityContent) + } +} + +func TestIsProtectedContextPath(t *testing.T) { + workspace := t.TempDir() + + if !isProtectedContextPath(workspace, filepath.Join(workspace, "memory", memoryFileName)) { + t.Fatal("expected memory path to be protected") + } + if !isProtectedContextPath(workspace, filepath.Join(workspace, identityFileName)) { + t.Fatal("expected identity path to be protected") + } + if isProtectedContextPath(workspace, filepath.Join(workspace, "notes.md")) { + t.Fatal("expected notes path to be unprotected") + } +} + +func TestEnsureWorkspaceRuntimeCreatesDefaultMemoryTemplate(t *testing.T) { + workspace := t.TempDir() + + ws, err := ensureWorkspaceRuntime(workspace) + if err != nil { + t.Fatalf("ensure workspace runtime failed: %v", err) + } + + content, err := readOptionalFile(filepath.Join(ws.memoryDir, memoryFileName)) + if err != nil { + t.Fatalf("read memory file failed: %v", err) + } + if !strings.Contains(content, "# Long-term Memory") { + t.Fatalf("expected memory template header, got: %q", content) + } + if !strings.Contains(content, "## User Information") { + t.Fatalf("expected user information section, got: %q", content) + } + if !strings.Contains(content, "## Preferences") { + t.Fatalf("expected preferences section, got: %q", content) + } +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/g.go b/adk/multiagent/openclaw-like-agent/myagent/g.go new file mode 100644 index 00000000..1db29073 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/g.go @@ -0,0 +1,324 @@ +package myagent + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func RunMyagentCmd() *cobra.Command { + var opts commandOptions + + cmd := &cobra.Command{ + Use: "myagent", + Short: "运行带 workspace/session 的本地 Agent CLI", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runChatCommand(cmd, opts) + }, + } + + bindSharedFlags(cmd.PersistentFlags(), &opts) + cmd.AddCommand(newChatCommand(&opts)) + cmd.AddCommand(newRunCommand(&opts)) + cmd.AddCommand(newSessionCommand(&opts)) + cmd.AddCommand(newSkillCommand(&opts)) + + return cmd +} + +type commandOptions struct { + Workspace string + SessionID string + Instruction string + Model string + BaseURL string + Query string + MaxIterations int +} + +func newChatCommand(shared *commandOptions) *cobra.Command { + local := *shared + cmd := &cobra.Command{ + Use: "chat", + Short: "进入交互式聊天模式", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runChatCommand(cmd, local) + }, + } + bindSharedFlags(cmd.Flags(), &local) + return cmd +} + +func newRunCommand(shared *commandOptions) *cobra.Command { + local := *shared + cmd := &cobra.Command{ + Use: "run [-q query] [query]", + Short: "执行单轮 agent 请求", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + query := strings.TrimSpace(local.Query) + if query == "" { + query = strings.TrimSpace(strings.Join(args, " ")) + } + if query == "" { + return errors.New("query 不能为空,请通过 -q 或位置参数传入") + } + return runSingleTurn(cmd, local, query) + }, + } + bindSharedFlags(cmd.Flags(), &local) + cmd.Flags().StringVarP(&local.Query, "query", "q", "", "单轮提问内容") + return cmd +} + +func newSessionCommand(shared *commandOptions) *cobra.Command { + local := *shared + cmd := &cobra.Command{ + Use: "session", + Short: "管理 session", + } + bindSessionFlags(cmd.PersistentFlags(), &local) + + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "列出当前 workspace 的全部 session", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rt, err := newRuntime(cmd.Context(), local) + if err != nil { + return err + } + metas, err := rt.store.ListSessions() + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(metas) == 0 { + fmt.Fprintln(out, "当前 workspace 还没有 session") + return nil + } + for _, meta := range metas { + fmt.Fprintf(out, "%s\tmessages=%d\tupdated=%s\tsummary=%s\n", + meta.SessionID, + meta.Count, + meta.UpdatedAt.Format("2006-01-02 15:04:05"), + trimForDisplay(meta.Summary, 48), + ) + } + return nil + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "clear", + Short: "清空当前 session 的历史与 summary", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rt, err := newRuntime(cmd.Context(), local) + if err != nil { + return err + } + if local.SessionID == "" { + return errors.New("session-id 不能为空") + } + if err := rt.store.ClearSession(local.SessionID); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "已清空 session: %s\n", local.SessionID) + return nil + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "delete", + Short: "删除当前 session 的全部持久化文件", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rt, err := newRuntime(cmd.Context(), local) + if err != nil { + return err + } + if local.SessionID == "" { + return errors.New("session-id 不能为空") + } + if err := rt.store.DeleteSession(local.SessionID); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "已删除 session: %s\n", local.SessionID) + return nil + }, + }) + + return cmd +} + +func bindSharedFlags(flags *pflag.FlagSet, opts *commandOptions) { + bindSessionFlags(flags, opts) + flags.StringVarP(&opts.Workspace, "workspace", "w", "", "workspace 目录,默认当前目录下的 myagent_workspace") + flags.StringVar(&opts.Instruction, "instruction", "", "覆盖默认 system instruction") + flags.StringVar(&opts.Model, "model", "", "模型名称") + flags.StringVar(&opts.BaseURL, "base-url", "", "openai base url") + flags.IntVar(&opts.MaxIterations, "max-iterations", 12, "agent loop 最大迭代次数") +} + +func bindSessionFlags(flags *pflag.FlagSet, opts *commandOptions) { + flags.StringVar(&opts.SessionID, "session-id", "", "session id;为空时自动创建") +} + +func runChatCommand(cmd *cobra.Command, opts commandOptions) error { + rt, err := newRuntime(cmd.Context(), opts) + if err != nil { + return err + } + out := cmd.OutOrStdout() + printWelcome(out, rt) + + scanner := bufio.NewScanner(cmd.InOrStdin()) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for { + fmt.Fprint(out, "\nmyagent> ") + if !scanner.Scan() { + fmt.Fprintln(out) + return scanner.Err() + } + + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if handled, err := handleSlashCommand(out, rt, line); handled { + if err != nil { + fmt.Fprintf(out, "error: %v\n", err) + } + if err == errExitChat { + return nil + } + continue + } + + if err := rt.RunTurn(context.Background(), line, out); err != nil { + fmt.Fprintf(out, "\nerror: %v\n", err) + } + } +} + +func runSingleTurn(cmd *cobra.Command, opts commandOptions, query string) error { + rt, err := newRuntime(cmd.Context(), opts) + if err != nil { + return err + } + printWelcome(cmd.OutOrStdout(), rt) + return rt.RunTurn(context.Background(), query, cmd.OutOrStdout()) +} + +func newSkillCommand(shared *commandOptions) *cobra.Command { + local := *shared + cmd := &cobra.Command{ + Use: "skill", + Short: "管理 skills", + } + cmd.Flags().StringVarP(&local.Workspace, "workspace", "w", "", "workspace 目录") + + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "列出所有来源目录中的 skills", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + workspacePath := strings.TrimSpace(local.Workspace) + if workspacePath == "" { + cwd, err := filepath.Abs(".") + if err != nil { + return fmt.Errorf("获取当前目录失败: %w", err) + } + workspacePath = filepath.Join(cwd, "myagent_workspace") + } + + cb := NewContextBuilder(workspacePath) + entries, err := listAllSkills(cb.skillsLoader) + if err != nil { + return err + } + return printSkillsList(cmd.OutOrStdout(), entries) + }, + }) + + return cmd +} + +var errExitChat = errors.New("exit chat") + +func handleSlashCommand(out io.Writer, rt *runtime, line string) (bool, error) { + switch strings.TrimSpace(line) { + case "/exit", "exit", "quit", "/quit": + fmt.Fprintln(out, "bye") + return true, errExitChat + case "/help": + fmt.Fprintln(out, "slash commands: /help /skills /session /history /clear /exit") + return true, nil + case "/skills": + cb := NewContextBuilder(rt.workspace.root) + entries, err := listAllSkills(cb.skillsLoader) + if err != nil { + return true, err + } + return true, printSkillsList(out, entries) + case "/session": + fmt.Fprintf(out, "session=%s workspace=%s\n", rt.sessionID, rt.workspace.root) + return true, nil + case "/history": + history, err := rt.store.GetHistory(rt.sessionID) + if err != nil { + return true, err + } + fmt.Fprintf(out, "history messages: %d\n", len(history)) + return true, nil + case "/clear": + if err := rt.store.ClearSession(rt.sessionID); err != nil { + return true, err + } + fmt.Fprintf(out, "已清空 session=%s\n", rt.sessionID) + return true, nil + default: + return false, nil + } +} + +func printSkillsList(out io.Writer, entries []skillDirEntry) error { + totalSkills := 0 + for _, entry := range entries { + totalSkills += len(entry.Skills) + } + if totalSkills == 0 { + _, err := fmt.Fprintln(out, "未发现任何 skill") + return err + } + + for _, entry := range entries { + if len(entry.Skills) == 0 { + continue + } + if _, err := fmt.Fprintf(out, "\n[%s]\n", entry.Label); err != nil { + return err + } + if _, err := fmt.Fprintf(out, " path: %s\n", entry.Path); err != nil { + return err + } + for _, sk := range entry.Skills { + if _, err := fmt.Fprintf(out, " %-28s %s\n", sk.Name, trimForDisplay(sk.Summary, 80)); err != nil { + return err + } + } + } + _, err := fmt.Fprintf(out, "\ntotal: %d skill(s)\n", totalSkills) + return err +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/runtime.go b/adk/multiagent/openclaw-like-agent/myagent/runtime.go new file mode 100644 index 00000000..c3d74de3 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/runtime.go @@ -0,0 +1,529 @@ +package myagent + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/cloudwego/eino-examples/adk/multiagent/openclaw-like-agent/myagent/backend/secureBackend" + "github.com/cloudwego/eino-ext/components/model/openai" + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/adk/filesystem" + filesystemMiddleware "github.com/cloudwego/eino/adk/middlewares/filesystem" + "github.com/cloudwego/eino/adk/middlewares/skill" + "github.com/cloudwego/eino/adk/middlewares/summarization" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" +) + +var extSkillDir = []string{} + +type runtime struct { + workspace *workspaceRuntime + sessionID string + store *jsonlSessionStore + runner *adk.Runner +} + +func newRuntime(ctx context.Context, opts commandOptions) (*runtime, error) { + workspacePath := strings.TrimSpace(opts.Workspace) + if workspacePath == "" { + cwd, err := filepath.Abs(".") + if err != nil { + return nil, fmt.Errorf("获取当前目录失败: %w", err) + } + workspacePath = filepath.Join(cwd, "myagent_workspace") + } + + ws, err := ensureWorkspaceRuntime(workspacePath) + if err != nil { + return nil, err + } + + sessionID := strings.TrimSpace(opts.SessionID) + if sessionID == "" { + sessionID = newSessionID() + } + if err := validateSessionID(sessionID); err != nil { + return nil, err + } + + store, err := newJSONLSessionStore(ws.sessionsDir) + if err != nil { + return nil, err + } + if err := store.EnsureSession(sessionID); err != nil { + return nil, err + } + + skillPaths := buildSkillPaths(ws.root, extSkillDir) + allowPaths, err := buildReadAllowPathPatterns(skillPaths) + if err != nil { + return nil, err + } + + backend, err := secureBackend.New(&secureBackend.Config{ + Workspace: ws.root, + Restrict: true, + AllowPaths: allowPaths, + ProtectedPaths: []string{ + filepath.Join(ws.root, "memory", memoryFileName), + filepath.Join(ws.root, identityFileName), + }, + }) + if err != nil { + return nil, fmt.Errorf("创建 secure backend 失败: %w", err) + } + apiKey := os.Getenv("OPENAI_API_KEY") + modelName := os.Getenv("OPENAI_MODEL") + baseURL := os.Getenv("OPENAI_BASE_URL") + byAzure := os.Getenv("OPENAI_BY_AZURE") == "true" + maxTokens := 16000 + + cm, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{ + APIKey: apiKey, + Model: modelName, + BaseURL: baseURL, + MaxTokens: &maxTokens, + ByAzure: byAzure, + }) + if err != nil { + return nil, fmt.Errorf("创建 openai chat model 失败: %w", err) + } + + handlers, err := loadSkillHandlers(ctx, backend, ws.root, skillPaths) + if err != nil { + return nil, err + } + + contextBuilder := NewContextBuilder(ws.root) + agentInstruction, err := contextBuilder.BuildInstruction(sessionID, strings.TrimSpace(opts.Instruction)) + if err != nil { + return nil, err + } + + tools, err := newWorkspaceTools(ws.root) + if err != nil { + return nil, err + } + + maxIterations := opts.MaxIterations + if maxIterations <= 0 { + maxIterations = 50 + } + + // 构建 filesystem middleware(对应 deep.Config.Backend 注册 read/write/edit/glob/grep 工具) + fsMw, err := filesystemMiddleware.New(ctx, &filesystemMiddleware.MiddlewareConfig{ + Backend: backend, + Shell: backend, + }) + if err != nil { + return nil, fmt.Errorf("创建 filesystem middleware 失败: %w", err) + } + + // summary + summaryMw, err := summarization.New(ctx, &summarization.Config{ + Model: cm, + Trigger: &summarization.TriggerCondition{ + ContextTokens: 100000, + }, + }) + if err != nil { + return nil, fmt.Errorf("创建 summary middleware 失败: %w", err) + } + + // 组合所有 handlers:filesystem middleware 优先,再追加 skill handlers + allHandlers := append([]adk.ChatModelAgentMiddleware{fsMw, summaryMw}, handlers...) + + agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "MyAgent", + Description: "workspace/session aware local agent", + Instruction: agentInstruction, + Model: cm, + ToolsConfig: adk.ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: tools, + // toolRetryMiddleware 先执行:对瞬态错误自动重试。 + // toolErrorRecoveryMiddleware 后执行:将最终错误转为模型可读的 result,避免 agent 崩溃。 + ToolCallMiddlewares: []compose.ToolMiddleware{toolRetryMiddleware(3), toolErrorRecoveryMiddleware()}, + }, + EmitInternalEvents: true, + }, + MaxIterations: maxIterations, + Handlers: allHandlers, + }) + if err != nil { + return nil, fmt.Errorf("创建 chat model agent 失败: %w", err) + } + + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: agent, + EnableStreaming: true, + }) + + return &runtime{ + workspace: ws, + sessionID: sessionID, + store: store, + runner: runner, + }, nil +} + +func (rt *runtime) RunTurn(ctx context.Context, userInput string, out io.Writer) error { + userInput = strings.TrimSpace(userInput) + if userInput == "" { + return errors.New("message 不能为空") + } + + history, err := rt.store.GetHistory(rt.sessionID) + if err != nil { + return err + } + + if err := rt.store.AddMessage(rt.sessionID, schema.UserMessage(userInput)); err != nil { + return err + } + + runMessages := append(cloneMessages(history), schema.UserMessage(userInput)) + printRunHeader(out, rt, userInput) + + events := rt.runner.Run(ctx, runMessages) + recorder := newTurnRecorder() + for { + event, ok := events.Next() + if !ok { + break + } + if event.Err != nil { + return fmt.Errorf("agent run 失败: %w", event.Err) + } + if err := renderAgentEvent(out, event, recorder); err != nil { + return err + } + } + + messages := recorder.Messages() + if len(messages) == 0 { + return errors.New("模型未返回任何消息") + } + + if err := rt.store.AddMessages(rt.sessionID, messages...); err != nil { + return err + } + if err := rt.store.TouchSummary(rt.sessionID, recorder.AssistantReply()); err != nil { + return err + } + + fmt.Fprintf(out, "\n[run.completed] session=%s saved_messages=%d at=%s\n", + rt.sessionID, + 1+len(messages), + time.Now().Format("15:04:05"), + ) + return nil +} + +func loadSkillHandlers(ctx context.Context, backend filesystem.Backend, resolveRoot string, paths []string) ([]adk.ChatModelAgentMiddleware, error) { + seen := make(map[string]struct{}, len(paths)) + handlers := make([]adk.ChatModelAgentMiddleware, 0, len(paths)) + for _, rawPath := range paths { + skillPath := strings.TrimSpace(rawPath) + if skillPath == "" { + continue + } + if !filepath.IsAbs(skillPath) { + skillPath = filepath.Join(resolveRoot, skillPath) + } + skillPath, err := filepath.Abs(skillPath) + if err != nil { + return nil, fmt.Errorf("解析 skill 路径失败 %q: %w", rawPath, err) + } + if _, ok := seen[skillPath]; ok { + continue + } + seen[skillPath] = struct{}{} + + info, err := osStat(skillPath) + if err != nil { + if errors.Is(err, errNotExist) { + continue + } + return nil, fmt.Errorf("检查 skill 路径失败 %q: %w", skillPath, err) + } + if !info.IsDir() { + continue + } + + skillBackend, err := skill.NewBackendFromFilesystem(ctx, &skill.BackendFromFilesystemConfig{ + Backend: backend, + BaseDir: skillPath, + }) + if err != nil { + return nil, fmt.Errorf("创建 skill backend 失败 %q: %w", skillPath, err) + } + middleware, err := skill.NewMiddleware(ctx, &skill.Config{ + Backend: skillBackend, + }) + if err != nil { + return nil, fmt.Errorf("创建 skill middleware 失败 %q: %w", skillPath, err) + } + handlers = append(handlers, middleware) + } + return handlers, nil +} + +// isTransientToolError reports whether err is a transient failure that is safe +// to retry automatically without involving the model (e.g. network blips, +// temporary I/O errors). Permanent errors such as "access denied" or "path +// escapes workspace" must NOT be retried — they need the model to correct the +// parameters instead. +func isTransientToolError(err error) bool { + if err == nil { + return false + } + // Context cancellation / deadline: never retry — the caller gave up. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + // Network-level transient conditions. + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + msg := err.Error() + transientPhrases := []string{ + "connection reset", + "connection refused", + "EOF", + "broken pipe", + "i/o timeout", + "temporary failure", + "try again", + "resource temporarily unavailable", + } + lower := strings.ToLower(msg) + for _, phrase := range transientPhrases { + if strings.Contains(lower, phrase) { + return true + } + } + return false +} + +// toolRetryMiddleware automatically retries a tool call up to maxAttempts times +// when a transient error is detected. Between retries it applies an +// exponential back-off (100 ms → 200 ms → 400 ms …) so as not to hammer a +// temporarily unavailable resource. Non-transient errors are passed through +// immediately so that toolErrorRecoveryMiddleware (which runs after this one) +// can surface them to the model as a structured [tool_error] result. +func toolRetryMiddleware(maxAttempts int) compose.ToolMiddleware { + if maxAttempts <= 0 { + maxAttempts = 3 + } + + backoff := func(attempt int) time.Duration { + d := 100 * time.Millisecond + for i := 0; i < attempt; i++ { + d *= 2 + } + if d > 2*time.Second { + d = 2 * time.Second + } + return d + } + + wrap := func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint { + return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) { + var ( + out *compose.ToolOutput + err error + ) + for attempt := 0; attempt < maxAttempts; attempt++ { + out, err = next(ctx, input) + if err == nil { + return out, nil + } + if !isTransientToolError(err) { + return nil, err + } + log.Printf("tool call transient error (attempt %d/%d): tool=%s error=%s", + attempt+1, maxAttempts, input.Name, err.Error()) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff(attempt)): + } + } + return nil, fmt.Errorf("tool %s failed after %d attempts: %w", input.Name, maxAttempts, err) + } + } + + wrapStream := func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint { + return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) { + var ( + out *compose.StreamToolOutput + err error + ) + for attempt := 0; attempt < maxAttempts; attempt++ { + out, err = next(ctx, input) + if err == nil { + return out, nil + } + if !isTransientToolError(err) { + return nil, err + } + log.Printf("streaming tool call transient error (attempt %d/%d): tool=%s error=%s", + attempt+1, maxAttempts, input.Name, err.Error()) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff(attempt)): + } + } + return nil, fmt.Errorf("tool %s failed after %d attempts: %w", input.Name, maxAttempts, err) + } + } + + return compose.ToolMiddleware{ + Invokable: wrap, + Streamable: wrapStream, + } +} + +// toolErrorRecoveryMiddleware converts tool execution errors into a structured +// error message that the model can read and reason about, instead of letting +// the error propagate up and kill the entire agent run. +// +// Without this middleware, any tool error (e.g. "access denied: path escapes +// workspace") causes the ToolNode to return an error to the graph, which +// terminates the run immediately with a cryptic stack trace shown to the user. +// +// With this middleware, the error is captured and returned to the model as a +// tool result string. The model can then explain the failure, suggest +// alternatives, or ask the user for clarification — exactly like a real +// terminal would print an error and keep the shell alive. +func toolErrorRecoveryMiddleware() compose.ToolMiddleware { + wrap := func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint { + return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) { + out, err := next(ctx, input) + if err == nil { + return out, nil + } + // Convert the error into a model-readable tool result. + // Prefix with [tool_error] so the model can distinguish it from + // normal output and respond appropriately. + msg := fmt.Sprintf("[tool_error] tool=%s error=%s", input.Name, err.Error()) + log.Printf("tool call error recovered as result: tool=%s error=%s", input.Name, err.Error()) + return &compose.ToolOutput{Result: msg}, nil + } + } + + // Apply the same recovery logic to streaming tools. + wrapStream := func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint { + return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) { + out, err := next(ctx, input) + if err == nil { + return out, nil + } + msg := fmt.Sprintf("[tool_error] tool=%s error=%s", input.Name, err.Error()) + log.Printf("streaming tool call error recovered as result: tool=%s error=%s", input.Name, err.Error()) + sr, sw := schema.Pipe[string](1) + sw.Send(msg, nil) + sw.Close() + return &compose.StreamToolOutput{Result: sr}, nil + } + } + + return compose.ToolMiddleware{ + Invokable: wrap, + Streamable: wrapStream, + } +} + +// buildSkillPaths assembles the full ordered list of skill directories to load. +// Priority (front = highest): workspace/.claude/skills, ~/.claude/skills, then +// any paths from config. Duplicates are removed while preserving order. +func buildSkillPaths(workspaceRoot string, configPaths []string) []string { + homeDir, _ := os.UserHomeDir() + + candidates := []string{ + filepath.Join(workspaceRoot, ".claude", "skills"), + filepath.Join(homeDir, ".claude", "skills"), + } + candidates = append(candidates, configPaths...) + + seen := make(map[string]struct{}, len(candidates)) + result := make([]string, 0, len(candidates)) + for _, p := range candidates { + p = strings.TrimSpace(p) + if p == "" { + continue + } + abs, err := filepath.Abs(p) + if err != nil { + continue + } + if _, ok := seen[abs]; ok { + continue + } + seen[abs] = struct{}{} + result = append(result, abs) + } + return result +} + +func buildReadAllowPathPatterns(paths []string) ([]*regexp.Regexp, error) { + seen := make(map[string]struct{}, len(paths)) + patterns := make([]*regexp.Regexp, 0, len(paths)) + sep := regexp.QuoteMeta(string(filepath.Separator)) + + for _, rawPath := range paths { + path := strings.TrimSpace(rawPath) + if path == "" { + continue + } + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("解析白名单路径失败 %q: %w", rawPath, err) + } + absPath = filepath.Clean(absPath) + if _, ok := seen[absPath]; ok { + continue + } + seen[absPath] = struct{}{} + + expr := "^" + regexp.QuoteMeta(absPath) + "(?:$|" + sep + ")" + pattern, err := regexp.Compile(expr) + if err != nil { + return nil, fmt.Errorf("编译白名单路径失败 %q: %w", absPath, err) + } + patterns = append(patterns, pattern) + } + + return patterns, nil +} + +func cloneMessages(messages []*schema.Message) []*schema.Message { + if len(messages) == 0 { + return nil + } + cloned := make([]*schema.Message, 0, len(messages)) + for _, message := range messages { + if message == nil { + continue + } + cp := *message + if len(message.ToolCalls) > 0 { + cp.ToolCalls = append([]schema.ToolCall(nil), message.ToolCalls...) + } + cloned = append(cloned, &cp) + } + return cloned +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/session_store.go b/adk/multiagent/openclaw-like-agent/myagent/session_store.go new file mode 100644 index 00000000..82654158 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/session_store.go @@ -0,0 +1,262 @@ +package myagent + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/cloudwego/eino/schema" +) + +var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +type sessionMeta struct { + SessionID string `json:"session_id"` + Summary string `json:"summary,omitempty"` + Skip int `json:"skip,omitempty"` + Count int `json:"count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type jsonlMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolCalls []schema.ToolCall `json:"tool_calls,omitempty"` +} + +type jsonlSessionStore struct { + root string +} + +func newJSONLSessionStore(root string) (*jsonlSessionStore, error) { + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("创建 sessions 目录失败: %w", err) + } + return &jsonlSessionStore{root: root}, nil +} + +func (s *jsonlSessionStore) EnsureSession(sessionID string) error { + if err := validateSessionID(sessionID); err != nil { + return err + } + if _, err := os.Stat(s.metaPath(sessionID)); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("检查 session meta 失败: %w", err) + } + now := time.Now().UTC() + return s.writeMeta(sessionID, sessionMeta{ + SessionID: sessionID, + CreatedAt: now, + UpdatedAt: now, + }) +} + +func (s *jsonlSessionStore) AddMessage(sessionID string, msg *schema.Message) error { + return s.AddMessages(sessionID, msg) +} + +func (s *jsonlSessionStore) AddMessages(sessionID string, messages ...*schema.Message) error { + if err := s.EnsureSession(sessionID); err != nil { + return err + } + if len(messages) == 0 { + return nil + } + + file, err := os.OpenFile(s.jsonlPath(sessionID), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("打开 session jsonl 失败: %w", err) + } + defer file.Close() + + enc := json.NewEncoder(file) + count := 0 + for _, msg := range messages { + if msg == nil { + continue + } + row := jsonlMessage{ + Role: string(msg.Role), + Content: msg.Content, + ToolCallID: msg.ToolCallID, + ToolName: msg.ToolName, + ToolCalls: append([]schema.ToolCall(nil), msg.ToolCalls...), + } + if err := enc.Encode(row); err != nil { + return fmt.Errorf("写入 session message 失败: %w", err) + } + count++ + } + + meta, err := s.readMeta(sessionID) + if err != nil { + return err + } + meta.Count += count + meta.UpdatedAt = time.Now().UTC() + return s.writeMeta(sessionID, meta) +} + +func (s *jsonlSessionStore) GetHistory(sessionID string) ([]*schema.Message, error) { + if err := s.EnsureSession(sessionID); err != nil { + return nil, err + } + file, err := os.Open(s.jsonlPath(sessionID)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("打开 session jsonl 失败: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var history []*schema.Message + for scanner.Scan() { + var row jsonlMessage + if err := json.Unmarshal(scanner.Bytes(), &row); err != nil { + return nil, fmt.Errorf("解析 session jsonl 失败: %w", err) + } + history = append(history, &schema.Message{ + Role: schema.RoleType(row.Role), + Content: row.Content, + ToolCallID: row.ToolCallID, + ToolName: row.ToolName, + ToolCalls: append([]schema.ToolCall(nil), row.ToolCalls...), + }) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("读取 session jsonl 失败: %w", err) + } + return history, nil +} + +func (s *jsonlSessionStore) TouchSummary(sessionID, assistantReply string) error { + meta, err := s.readMeta(sessionID) + if err != nil { + return err + } + if strings.TrimSpace(meta.Summary) == "" && strings.TrimSpace(assistantReply) != "" { + meta.Summary = trimForDisplay(assistantReply, 120) + } + meta.UpdatedAt = time.Now().UTC() + return s.writeMeta(sessionID, meta) +} + +func (s *jsonlSessionStore) ClearSession(sessionID string) error { + if err := s.EnsureSession(sessionID); err != nil { + return err + } + if err := os.WriteFile(s.jsonlPath(sessionID), nil, 0o644); err != nil { + return fmt.Errorf("清空 session jsonl 失败: %w", err) + } + meta, err := s.readMeta(sessionID) + if err != nil { + return err + } + meta.Summary = "" + meta.Skip = 0 + meta.Count = 0 + meta.UpdatedAt = time.Now().UTC() + return s.writeMeta(sessionID, meta) +} + +func (s *jsonlSessionStore) DeleteSession(sessionID string) error { + if err := validateSessionID(sessionID); err != nil { + return err + } + if err := os.Remove(s.jsonlPath(sessionID)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除 session jsonl 失败: %w", err) + } + if err := os.Remove(s.metaPath(sessionID)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除 session meta 失败: %w", err) + } + return nil +} + +func (s *jsonlSessionStore) ListSessions() ([]sessionMeta, error) { + entries, err := os.ReadDir(s.root) + if err != nil { + return nil, fmt.Errorf("读取 sessions 目录失败: %w", err) + } + var metas []sessionMeta + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { + continue + } + sessionID := strings.TrimSuffix(entry.Name(), ".meta.json") + meta, err := s.readMeta(sessionID) + if err != nil { + return nil, err + } + metas = append(metas, meta) + } + sort.Slice(metas, func(i, j int) bool { + return metas[i].UpdatedAt.After(metas[j].UpdatedAt) + }) + return metas, nil +} + +func (s *jsonlSessionStore) readMeta(sessionID string) (sessionMeta, error) { + data, err := os.ReadFile(s.metaPath(sessionID)) + if err != nil { + return sessionMeta{}, fmt.Errorf("读取 session meta 失败: %w", err) + } + var meta sessionMeta + if err := json.Unmarshal(data, &meta); err != nil { + return sessionMeta{}, fmt.Errorf("解析 session meta 失败: %w", err) + } + return meta, nil +} + +func (s *jsonlSessionStore) writeMeta(sessionID string, meta sessionMeta) error { + payload, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("序列化 session meta 失败: %w", err) + } + if err := os.WriteFile(s.metaPath(sessionID), payload, 0o644); err != nil { + return fmt.Errorf("写入 session meta 失败: %w", err) + } + return nil +} + +func (s *jsonlSessionStore) jsonlPath(sessionID string) string { + return filepath.Join(s.root, sessionID+".jsonl") +} + +func (s *jsonlSessionStore) metaPath(sessionID string) string { + return filepath.Join(s.root, sessionID+".meta.json") +} + +func validateSessionID(sessionID string) error { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return fmt.Errorf("session id 不能为空") + } + if !sessionIDPattern.MatchString(sessionID) { + return fmt.Errorf("非法 session id %q,仅支持字母、数字、下划线和中划线", sessionID) + } + return nil +} + +func newSessionID() string { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return fmt.Sprintf("sess_%d", time.Now().UnixNano()) + } + return "sess_" + hex.EncodeToString(buf[:]) +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/tools.go b/adk/multiagent/openclaw-like-agent/myagent/tools.go new file mode 100644 index 00000000..76d8f534 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/tools.go @@ -0,0 +1,89 @@ +package myagent + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/components/tool/utils" +) + +type writeFileInput struct { + Path string `json:"path" jsonschema:"description=待写入文件路径"` + Content string `json:"content" jsonschema:"description=完整文件内容"` +} + +type appendFileInput struct { + Path string `json:"path" jsonschema:"description=待追加文件路径"` + Content string `json:"content" jsonschema:"description=要追加的内容"` +} + +type fileMutationOutput struct { + Path string `json:"path"` + Status string `json:"status"` +} + +func newWorkspaceTools(workspaceRoot string) ([]tool.BaseTool, error) { + appendTool, err := utils.InferTool("append_file", fmt.Sprintf("追加写入 %s 内文件。", workspaceRoot), func(ctx context.Context, input appendFileInput) (fileMutationOutput, error) { + return writeWorkspaceFile(workspaceRoot, input.Path, input.Content, true) + }) + if err != nil { + return nil, err + } + + return []tool.BaseTool{appendTool}, nil +} + +func writeWorkspaceFile(workspaceRoot, target, content string, appendMode bool) (fileMutationOutput, error) { + if strings.TrimSpace(target) == "" { + return fileMutationOutput{}, errors.New("path 不能为空") + } + absPath, err := resolveWorkspacePath(workspaceRoot, target) + if err != nil { + return fileMutationOutput{}, err + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fileMutationOutput{}, fmt.Errorf("创建目录失败: %w", err) + } + flag := os.O_CREATE | os.O_WRONLY + status := "written" + if appendMode { + flag |= os.O_APPEND + status = "appended" + } else { + flag |= os.O_TRUNC + } + file, err := os.OpenFile(absPath, flag, 0o644) + if err != nil { + return fileMutationOutput{}, fmt.Errorf("打开文件失败: %w", err) + } + defer file.Close() + if _, err := file.WriteString(content); err != nil { + return fileMutationOutput{}, fmt.Errorf("写入文件失败: %w", err) + } + return fileMutationOutput{Path: absPath, Status: status}, nil +} + +func resolveWorkspacePath(workspaceRoot, target string) (string, error) { + if strings.TrimSpace(target) == "" { + return "", errors.New("path 不能为空") + } + var absPath string + if filepath.IsAbs(target) { + absPath = filepath.Clean(target) + } else { + absPath = filepath.Clean(filepath.Join(workspaceRoot, target)) + } + rel, err := filepath.Rel(workspaceRoot, absPath) + if err != nil { + return "", fmt.Errorf("解析路径失败: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("路径越界: %s 不在 workspace 内", absPath) + } + return absPath, nil +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/ui.go b/adk/multiagent/openclaw-like-agent/myagent/ui.go new file mode 100644 index 00000000..564c6cc9 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/ui.go @@ -0,0 +1,292 @@ +package myagent + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" +) + +type turnRecorder struct { + assistantParts []string + messages []*schema.Message + toolNames map[string]string + printedCalls map[string]struct{} + printedResults map[string]struct{} +} + +func newTurnRecorder() *turnRecorder { + return &turnRecorder{ + toolNames: make(map[string]string), + printedCalls: make(map[string]struct{}), + printedResults: make(map[string]struct{}), + } +} + +func (r *turnRecorder) AddMessage(message *schema.Message) { + if message == nil { + return + } + cp := *message + if len(message.ToolCalls) > 0 { + cp.ToolCalls = append([]schema.ToolCall(nil), message.ToolCalls...) + } + r.messages = append(r.messages, &cp) + if cp.Role == schema.Assistant && cp.Content != "" { + r.assistantParts = append(r.assistantParts, cp.Content) + } + for _, tc := range cp.ToolCalls { + if tc.ID != "" && tc.Function.Name != "" { + r.toolNames[tc.ID] = tc.Function.Name + } + } + if cp.Role == schema.Tool && cp.ToolCallID != "" && cp.ToolName != "" { + r.toolNames[cp.ToolCallID] = cp.ToolName + } +} + +func (r *turnRecorder) Messages() []*schema.Message { + return cloneMessages(r.messages) +} + +func (r *turnRecorder) AssistantReply() string { + return strings.TrimSpace(strings.Join(r.assistantParts, "")) +} + +func printWelcome(out io.Writer, rt *runtime) { + fmt.Fprintf(out, "MyAgent TUI\n") + fmt.Fprintf(out, "workspace: %s\n", rt.workspace.root) + fmt.Fprintf(out, "session: %s\n", rt.sessionID) + fmt.Fprintf(out, "commands: /help /session /history /clear /exit /skills\n") +} + +func printRunHeader(out io.Writer, rt *runtime, query string) { + fmt.Fprintf(out, "\n[%s] user> %s\n", time.Now().Format("15:04:05"), query) + fmt.Fprintf(out, "[run.started] session=%s workspace=%s\n", rt.sessionID, rt.workspace.root) +} + +func renderAgentEvent(out io.Writer, event *adk.AgentEvent, recorder *turnRecorder) error { + if event == nil { + return nil + } + + if event.Output != nil && event.Output.MessageOutput != nil { + if msg := event.Output.MessageOutput.Message; msg != nil && shouldRenderMessage(msg) { + renderMessage(out, msg, recorder) + recorder.AddMessage(msg) + } + if stream := event.Output.MessageOutput.MessageStream; stream != nil { + message, err := collectStreamMessage(out, stream, recorder) + if err != nil { + return err + } + if message != nil { + recorder.AddMessage(message) + } + } + } + if event.Action != nil && event.Action.Exit { + fmt.Fprintln(out, "\n[status] agent exited current loop") + } + return nil +} + +func collectStreamMessage(out io.Writer, stream *schema.StreamReader[*schema.Message], recorder *turnRecorder) (*schema.Message, error) { + var ( + chunks []*schema.Message + assistantOpened bool + ) + for { + chunk, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("读取消息流失败: %w", err) + } + if chunk == nil { + continue + } + cp := *chunk + if len(chunk.ToolCalls) > 0 { + cp.ToolCalls = append([]schema.ToolCall(nil), chunk.ToolCalls...) + cacheToolNames(recorder, chunk.ToolCalls) + } + if cp.Content != "" { + if cp.Role != schema.Tool { + if !assistantOpened { + fmt.Fprint(out, "[assistant] ") + assistantOpened = true + } + fmt.Fprint(out, cp.Content) + } + } + chunks = append(chunks, &cp) + } + if assistantOpened { + fmt.Fprintln(out) + } + if len(chunks) == 0 { + return nil, nil + } + message, err := schema.ConcatMessages(chunks) + if err != nil { + return nil, fmt.Errorf("合并消息流失败: %w", err) + } + renderMessage(out, message, recorder) + return message, nil +} + +func renderMessage(out io.Writer, msg *schema.Message, recorder *turnRecorder) { + if msg == nil { + return + } + cacheToolNames(recorder, msg.ToolCalls) + printToolCallsWithRecorder(out, recorder, msg.ToolCalls) + switch msg.Role { + case schema.Tool: + key := toolResultKey(msg) + if recorder != nil { + if _, ok := recorder.printedResults[key]; ok { + return + } + recorder.printedResults[key] = struct{}{} + } + fmt.Fprintf(out, "[tool.call] name=%s output=%s\n", resolveToolName(recorder, msg), trimForDisplay(msg.Content, 240)) + case schema.Assistant: + if len(msg.ToolCalls) == 0 && msg.Content != "" { + fmt.Fprintf(out, "[assistant] %s\n", msg.Content) + } + } +} + +func cacheToolNames(recorder *turnRecorder, calls []schema.ToolCall) { + if recorder == nil { + return + } + for _, tc := range calls { + if tc.ID != "" && tc.Function.Name != "" { + recorder.toolNames[tc.ID] = tc.Function.Name + } + } +} + +func printToolCalls(out io.Writer, calls []schema.ToolCall) { + printToolCallsWithRecorder(out, nil, calls) +} + +func printToolCallsWithRecorder(out io.Writer, recorder *turnRecorder, calls []schema.ToolCall) { + for _, tc := range calls { + if !isCompleteToolCall(tc) { + continue + } + key := toolCallKey(tc) + if recorder != nil { + if _, ok := recorder.printedCalls[key]; ok { + continue + } + recorder.printedCalls[key] = struct{}{} + } + fmt.Fprintf(out, "[tool.call.start] name=%s args=%s\n", tc.Function.Name, trimForDisplay(tc.Function.Arguments, 160)) + } +} + +func resolveToolName(recorder *turnRecorder, msg *schema.Message) string { + if msg == nil { + return "unknown" + } + if msg.ToolName != "" { + return msg.ToolName + } + if recorder != nil && msg.ToolCallID != "" { + if name := recorder.toolNames[msg.ToolCallID]; name != "" { + return name + } + } + return "unknown" +} + +func shouldRenderMessage(msg *schema.Message) bool { + if msg == nil { + return false + } + if msg.Role == schema.Tool { + return strings.TrimSpace(msg.Content) != "" + } + if len(msg.ToolCalls) > 0 { + for _, tc := range msg.ToolCalls { + if isCompleteToolCall(tc) { + return true + } + } + } + if msg.Role == schema.Assistant && strings.TrimSpace(msg.Content) != "" { + return true + } + return false +} + +func isCompleteToolCall(tc schema.ToolCall) bool { + if strings.TrimSpace(tc.Function.Name) == "" { + return false + } + args := strings.TrimSpace(tc.Function.Arguments) + if args == "" { + return false + } + return json.Valid([]byte(args)) +} + +func toolCallKey(tc schema.ToolCall) string { + if tc.ID != "" { + return tc.ID + } + return tc.Function.Name + "|" + tc.Function.Arguments +} + +func toolResultKey(msg *schema.Message) string { + if msg == nil { + return "" + } + if msg.ToolCallID != "" { + return msg.ToolCallID + "|" + msg.Content + } + return msg.ToolName + "|" + msg.Content +} + +func trimForDisplay(s string, max int) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) + if max <= 0 || len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} + +func trimForPrompt(s string, max int) string { + s = strings.TrimSpace(s) + if max <= 0 || len(s) <= max { + return s + } + return s[:max] + "\n...(truncated)" +} + +func firstNonEmptyLine(s string) string { + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) != "" { + return strings.TrimSpace(line) + } + } + return "" +} + +func nowRFC3339() string { + return time.Now().Format(time.RFC3339) +} diff --git a/adk/multiagent/openclaw-like-agent/myagent/workspace.go b/adk/multiagent/openclaw-like-agent/myagent/workspace.go new file mode 100644 index 00000000..477938d9 --- /dev/null +++ b/adk/multiagent/openclaw-like-agent/myagent/workspace.go @@ -0,0 +1,204 @@ +package myagent + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + identityFileName = "IDENTITY.md" + memoryFileName = "MEMORY.md" +) + +var errNotExist = errors.New("not exist") + +type fileInfo interface { + IsDir() bool +} + +type workspaceRuntime struct { + root string + memoryDir string + skillsDir string + sessionsDir string + artifactsDir string + logsDir string +} + +func ensureWorkspaceRuntime(root string) (*workspaceRuntime, error) { + root = strings.TrimSpace(root) + if root == "" { + return nil, errors.New("workspace 不能为空") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("解析 workspace 路径失败: %w", err) + } + + ws := &workspaceRuntime{ + root: absRoot, + memoryDir: filepath.Join(absRoot, "memory"), + skillsDir: filepath.Join(absRoot, "skills"), + sessionsDir: filepath.Join(absRoot, "sessions"), + artifactsDir: filepath.Join(absRoot, "artifacts"), + logsDir: filepath.Join(absRoot, "logs"), + } + + for _, dir := range []string{ws.root, ws.memoryDir, ws.skillsDir, ws.sessionsDir, ws.artifactsDir, ws.logsDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("创建 workspace 目录失败 %s: %w", dir, err) + } + } + + if err := ensureFileWithDefault(filepath.Join(ws.root, identityFileName), defaultIdentityMarkdown()); err != nil { + return nil, err + } + if err := ensureFileWithDefault(filepath.Join(ws.memoryDir, memoryFileName), defaultMemoryMarkdown()); err != nil { + return nil, err + } + + return ws, nil +} + +func defaultIdentityMarkdown() string { + return "# Identity\n\nYou are MyAgent, a concise engineering assistant that works step by step.\n" +} + +func defaultMemoryMarkdown() string { + return `# Long-term Memory + +This file stores important information that should persist across sessions. + +## User Information + +(Important facts about user) + +## Preferences + +(User preferences learned over time) +... +` +} + +func ensureFileWithDefault(path, content string) error { + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("检查文件失败 %s: %w", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("写入默认文件失败 %s: %w", path, err) + } + return nil +} + +func readOptionalFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("读取文件失败 %s: %w", path, err) + } + return string(data), nil +} + +// skillInfo holds metadata about a single discovered skill. +type skillInfo struct { + Name string + Dir string + Summary string +} + +// skillDirEntry groups skills found under one logical source directory. +type skillDirEntry struct { + Label string + Path string + Skills []skillInfo +} + +// listAllSkills returns all skills across every known source directory in +// priority order: workspace/skills, workspace/.claude/skills, +// ~/.claude/skills, global config skills, builtin skills. +func listAllSkills(loader *SkillsLoader) ([]skillDirEntry, error) { + sources := []struct { + label string + path string + }{ + {"Workspace Skills", loader.workspaceSkillsDir}, + {"Workspace .claude Skills", loader.dotClaudeWorkspaceSkillsDir}, + {"Home .claude Skills", loader.dotClaudeHomeSkillsDir}, + {"Global Skills", loader.globalSkillsDir}, + {"Builtin Skills", loader.builtinSkillsDir}, + } + + var result []skillDirEntry + for _, src := range sources { + skills, err := collectSkillInfos(src.path) + if err != nil { + return nil, err + } + result = append(result, skillDirEntry{ + Label: src.label, + Path: src.path, + Skills: skills, + }) + } + return result, nil +} + +func collectSkillInfos(skillsDir string) ([]skillInfo, error) { + entries, err := os.ReadDir(skillsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("读取 skills 目录失败: %w", err) + } + var infos []skillInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + skillPath := filepath.Join(skillsDir, entry.Name(), "SKILL.md") + content, err := readOptionalFile(skillPath) + if err != nil { + return nil, err + } + if strings.TrimSpace(content) == "" { + continue + } + infos = append(infos, skillInfo{ + Name: entry.Name(), + Dir: filepath.Join(skillsDir, entry.Name()), + Summary: firstNonEmptyLine(content), + }) + } + return infos, nil +} + +func collectSkillSummary(skillsDir string) (string, error) { + infos, err := collectSkillInfos(skillsDir) + if err != nil { + return "", err + } + lines := make([]string, 0, len(infos)) + for _, info := range infos { + lines = append(lines, fmt.Sprintf("- %s: %s", info.Name, trimForDisplay(info.Summary, 120))) + } + return strings.Join(lines, "\n"), nil +} + +func osStat(path string) (fileInfo, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, errNotExist + } + return nil, err + } + return info, nil +} diff --git a/go.mod b/go.mod index 9cacd265..31ba193b 100644 --- a/go.mod +++ b/go.mod @@ -69,6 +69,7 @@ require ( github.com/goph/emperror v0.17.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/mux v1.8.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -90,6 +91,8 @@ require ( github.com/richardlehane/msoleps v1.0.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tiendc/go-deepcopy v1.7.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index 5168dc4a..63637700 100644 --- a/go.sum +++ b/go.sum @@ -173,6 +173,7 @@ github.com/coze-dev/cozeloop-go v0.1.20/go.mod h1:lM7cmUEZlnAlQYdwfk4Li0SC3RdZ++ github.com/coze-dev/cozeloop-go/spec v0.1.8 h1:hFVBj/C1B6mUNGH/q52kO2n1pXuTomG578RbKlfYLGk= github.com/coze-dev/cozeloop-go/spec v0.1.8/go.mod h1:/f3BrWehffwXIpd4b5rYIqktLd/v5dlLBw0h9F/LQIU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= @@ -368,6 +369,8 @@ github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKEN github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= @@ -561,6 +564,7 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= @@ -580,6 +584,12 @@ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sS github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= @@ -661,6 +671,7 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU= golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=