- Add pkg/orch/reporter.go: AgentReporter interface + Noop singleton
- Broadcaster now implements AgentReporter (ReportSpawn/StateChange/Conversation/GC)
- ToolLoopConfig: replace OnStateChange func with Reporter+AgentID
- SubagentManager: accept AgentReporter in constructor, remove internal Broadcaster
and GetBroadcaster(); all Publish calls replaced with Report* calls
- AgentLoop: add orchBroadcaster/*orchReporter fields, reporter() nil-safe helper,
SetOrchReporter/GetOrchBroadcaster public API
- NewAgentLoop: create struct before registerSharedTools so al.reporter() is
available; auto-detect orchestration from registry config
- runAgentLoop: ReportSpawn on entry, defer ReportGC on exit
- runLLMIteration: ReportStateChange("waiting") before LLM call,
ReportStateChange("toolcall", name) before each tool execution
- cmd_gateway.go: wire GetOrchBroadcaster() → handler.SetOrchBroadcaster()
- Update subagent_tool_test.go for new constructor signature
- Document hierarchy in CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
20 KiB
CLAUDE.md
Project: picoclaw
Go-based AI agent with multi-channel messaging (Telegram, Discord, Slack, etc.) and a Telegram Mini App UI.
Build & Test
go build ./...
go test ./...
go vet ./...
Lint: golangci-lint run
Plan Mode
- Interview tool filtering:
interviewAllowedToolsinpkg/agent/loop.gois the single source of truth for tools available during interview/review phases. BothfilterInterviewTools(strips definitions before LLM call) andisToolAllowedDuringInterview(argument-level gating) reference this map. - History clear:
/plan start clearwipes session history and summary on transition to executing. The Mini App review UI offers two sliders: standard approve and approve-with-clear.
Known Gaps
- Mini App log viewer has no frontend tests:
renderLogs()inpkg/miniapp/static/index.htmlis inline vanilla JS with no unit/E2E test coverage. Backend (Go) tests coverRecentLogs,SanitizeFields, and JSON serialization, but nothing verifies the JS rendering. This allowed the Fields display bug (fields sent but not rendered) to ship undetected. - No human intervention for heartbeat worktrees: Heartbeat sessions create git worktrees (
.worktrees/heartbeat-YYYYMMDD/) but there is no CLI or Mini App command to list, inspect, or manually dispose them. Need a/plan worktreescommand (or similar) that shows active worktrees with branch/commit info and allows manual merge/dispose.PruneOrphanedon startup only removes directories without auto-committing first, so uncommitted changes in orphaned worktrees are silently lost.
Subagent Orchestration Design
Designed 2026-02-25 on branch
sub-agent-technical-breakdown.
なぜオーケストレーションか
単純な指示から可能性の木を広げることが目的。conductor は一人でやり遂げるのではなく、探索・深化・fork をサブエージェントに委ねながら大局観を保つ。
without orchestration:
human → conductor → (全部自分でやる) → result
常にボトルネック、逐次処理
with orchestration:
human → conductor ─┬─ scout A ─┐
├─ scout B ─┼─ synthesize → deeper insight
└─ scout C ─┘
conductor は次を考えながら並走
3つの核心原則:
- Fork — 同じ問いに複数の切り口で同時探索。sequential queue ではなく tree の展開。
- 管理職の原則 — conductor は subagent の完了を待たない。spawn したら即座に次を設計する。人員を遊ばせないことがポイント。
- 会話の fork — main thread (conductor ↔ human) は高レベル・戦略的に保つ。subagent への細かい指示出しは branch thread で行い、main thread を汚染しない。subagent の進捗はサマリーだけ main thread に上げる。
spawn がデフォルト、subagent は例外:
spawn = conductor が次を考え続けられる (正しい姿)
subagent = conductor が止まる (結果が絶対必要な時だけ)
Architecture Overview
Conductor goroutine
│ ContainerRequest (task, preset, environment)
▼
Container goroutine: provision → run → finalize
│ ContainerMessage (question / result / status)
▼
Conductor goroutine
│ answer (question への回答)
▼ (わからなければ human に escalate)
Container goroutine (再開)
escalation chain:
subagent (clarifying) → question → conductor
→ conductor が答えられる: inCh に回答
→ conductor もわからない: message tool で human に投げ、回答を転送
SubagentContainer
goroutine と channel でライフサイクルを表現。goroutine がブロックしている場所が現在の状態。
type ContainerMessage struct {
Type string // "question" | "result" | "status"
Content string
}
type SubagentContainer struct {
inCh chan string // conductor → subagent (回答)
outCh chan ContainerMessage // subagent → conductor (質問・結果・進捗)
cancel context.CancelFunc
}
spawn (async) は outCh を返して即リターン。subagent (sync) はその場で result を待つ。
tasks map 問題の解消: goroutine 終了時に defer orchestrator.active.Delete(id) + defer close(outCh) で自動 GC。
SubagentEnvironment (Context Injection)
conductor は subagent に必要なコンテキストを明示的に渡す。MEMORY.md からの自動注入で冗長な手動記述を排除。
type SubagentEnvironment struct {
// 自動注入 (harness が埋める)
Workspace string // workspace パス
WorktreeDir string // write先、workDir として透過的に機能
// MEMORY.md から自動抽出 (inject_plan_context: true の場合)
PlanTask string // > Task: の内容
PlanContext string // ## Context セクション
Commands string // ## Commands セクション (build/test/lint)
CurrentPhase string // 対象 Phase の内容
// conductor が明示的に追加
Background string // 追加の背景・意図
Constraints string // 制約
ContextFiles []string // 参照すべきファイルリスト
}
spawn パラメータ例:
{
"task": "Phase 2 Step 1: implement the rate limiter",
"preset": "coder",
"context_files": ["pkg/ratelimit/ratelimit.go"],
"inject_plan_context": true
}
SandboxConfig
ToolRegistry.Execute() の入口で一括 enforcement。subagent は普通に tool call するつもりで透過的に sandboxed になる。
type SandboxConfig struct {
Preset string
WriteRoot string // write 系ツールのパス制限
AllowedTools map[string]bool
ExecPolicy *ExecPolicy // nil = exec 不可
SpawnablePresets []string // nil = spawn 不可
}
type ExecPolicy struct {
AllowPattern string // 先頭一致 regex; マッチしたコマンドだけ実行可
}
透過的隔離: workDir = worktreeDir として設定することで、AI は自分が隔離されていることに気づかずに振る舞う。picoclaw 側で CoW 的にファイルを引き渡せる。
Presets (5種)
| preset | 性格 | write | exec | search | spawn |
|---|---|---|---|---|---|
scout |
Exploratory | ✗ | ✗ | ✓ | ✗ |
analyst |
Exploratory | ✗ | go test/vet, git log/diff, grep | ✓ | ✗ |
coder |
Deliberate | ✓ sandbox | test/lint/fmt 系 | ✓ | ✗ |
worker |
Deliberate | ✓ sandbox | build/package manager 系 | ✓ | ✗ |
coordinator |
Deliberate | ✓ sandbox | go/pnpm/bun/curl 系 | ✓ | scout〜worker のみ |
性格の分類:
- Exploratory (scout/analyst): open-ended、見てきて報告。clarifying フェーズなし。
- Deliberate (coder/worker/coordinator): 成果物を作る。目標があいまいだと失敗する。clarifying フェーズあり。
境界:
coder= 書いて自分で検証できる (package 追加・deploy 不可)worker= インフラも含めてやりきる (package install, CI pipeline 等)coordinator= coordinator を spawn できない (深さ自然制限)
npm は全 preset で禁止: git worktree に node_modules が作られると大量ファイルが生じるため。pnpm は symbolic link で済む、bun も同様。
websearch/webfetch は全 preset で許可: read-only・非破壊のため制限不要。
exec allowlist regex
var presetExecPatterns = map[string]string{
"scout": ``,
"analyst": `^(go\s+(test|vet)|git\s+(log|diff|status)|curl|wget|grep|find)\b`,
"coder": `^(` +
`go\s+(test|vet|fmt)|gofmt|goimports|golangci-lint|` +
`prettier|eslint|` +
`black|ruff|` +
`cargo\s+(test|fmt|clippy)|` +
`pnpm\s+(test|run\s+(test|lint|format))|` +
`bun\s+(test|run\s+(test|lint|format))|` +
`uv\s+run\s+` +
`)\b`,
"worker": `^(` +
`go\s+|` +
`pnpm\s+(install|add|run|test|build)|` +
`bun\s+(install|add|run|test|build)|` +
`uv\s+(run|sync|add|pip\s+install)|` +
`pip\s+install|` +
`cargo\s+` +
`)\b`,
"coordinator": `^(go\s+|pnpm\s+|bun\s+|curl|wget)\b`,
}
Subagent Plan Mode
Deliberate な preset (coder/worker/coordinator) は in-memory のミニ plan mode を持つ。MEMORY.md には一切触れない (ファイル参照・編集を避けるため)。
type SubagentPlanState int
const (
PlanStateNone SubagentPlanState = iota // Exploratory preset
PlanStateClarifying // 目的・制約を確認中
PlanStateReview // conductor の承認待ち
PlanStateExecuting // 実行中
)
type SubagentPlan struct {
State SubagentPlanState
Goal string // clarifying で合意した目的
Approach []string // proposed なステップ
QA []QAItem // 質問・回答の履歴
mu sync.Mutex
}
SubagentContainer がフィールドとして保持。goroutine 終了とともに消える。
Deliberate preset の system prompt:
You are in clarifying mode. Before executing, confirm with the conductor:
1. What is the exact goal?
2. What are the constraints and acceptance criteria?
3. Are there relevant files I should know about?
Use the `message` tool to ask questions.
When you have clear answers, propose your approach (steps) for review.
Do NOT start executing until the conductor approves.
Exploratory preset の system prompt:
Explore and return findings. Use your best judgment when encountering ambiguity.
fractal 構造:
human
↕ plan mode (MEMORY.md, file-based, 永続)
conductor
↕ subagent plan mode (in-memory, 揮発)
subagent (deliberate)
MEMORY.md Orchestration Section
executing 中に conductor が自由に書き込める専用エリア。システムはパースしない。
## Orchestration
### Delegated
<!-- subagent に委譲したタスクの記録 (spawn 直後に書く) -->
- coder-1 (coder): rate limiter 実装 → Phase 2 Step 1
- scout-1 (scout): pkg/auth の構造調査
### Findings
<!-- subagent の結果から蓄積した知見 (結果受信後に書く) -->
- pkg/auth は middleware パターン、入口は middleware.go (scout-1)
- セッションストアは存在しない、JWT が有効 (scout-2)
### Decisions
<!-- fork の意思決定ログ (方向選択時に書く) -->
- auth: OAuth2 より JWT を選択 (外部依存なし、scout-2 推奨)
conductor の guidance に追記:
After spawning a subagent, record the assignment in ## Orchestration > Delegated.
When a subagent reports back, move key findings to ## Orchestration > Findings.
When you choose one direction over another, log the rationale in ## Orchestration > Decisions.
Conductor Identity (System Prompt)
pkg/agent/context.go の getIdentity() に追加予定:
You are picoclaw, a conductor AI agent. Your role is to orchestrate:
break work into tasks, delegate them to subagents, and synthesize results —
rather than doing everything inline yourself.
## Orchestration
You are the conductor, not the performer. Prefer delegation over doing everything inline.
Use `spawn` (non-blocking) when:
- Tasks can run in parallel or in the background
- Multiple independent tasks can run simultaneously (spawn each one)
- You don't need the result to decide the next step
- The operation is long-running (builds, fetches, analysis, file processing)
Use `subagent` (blocking) when:
- You need the result before you can continue
- Correctness of next steps depends on the outcome
Do inline only when:
- It's a single fast tool call (read a file, quick search)
- Delegation overhead clearly outweighs the benefit
Default bias: if a task involves more than 2-3 tool calls or can run
independently, delegate it. When you spawn, immediately plan what comes next —
blocking means you've stopped thinking.
Fork aggressively: explore multiple directions simultaneously.
After spawning a subagent, record the assignment in ## Orchestration > Delegated.
When results come back, synthesize and decide the next fork.
Startup Flag
テスト用途で起動時にオーケストレーション機能を on/off できるようにする。
変更箇所:
pkg/config/config.go—SubagentsConfigにEnabled boolを追加cmd/picoclaw/cmd_agent.go—--orchestrationフラグを追加 (default: false for now)pkg/agent/loop.go—registerSharedTools()で spawn tool 登録をEnabledで gate
// pkg/config/config.go
type SubagentsConfig struct {
Enabled bool `json:"enabled"`
AllowAgents []string `json:"allow_agents,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
}
// cmd/picoclaw/cmd_agent.go
case "--orchestration":
cfg.Agents.Defaults.Subagents.Enabled = true // or toggle
Implementation Files (予定)
pkg/tools/
container.go — SubagentContainer, ContainerRequest, ContainerMessage,
SubagentEnvironment, SubagentPlan
sandbox.go — SandboxConfig, ExecPolicy, preset 定義
orchestrator.go — Orchestrator (SubagentManager を置き換え)
spawn.go — preset / inject_plan_context パラメータ追加
pkg/agent/
context.go — conductor identity + orchestration guidance 追加
AgentReporter 抽象化 (実装済み 2026-02-25)
branch
sub-agent-technical-breakdown
Broadcaster を SubagentManager 内部で生成する密結合を解消し、
orch.AgentReporter インターフェースを中心に置くリファクタリングを実施。
オーナーシップ
AgentLoop
├─ owns: *orch.Broadcaster (orchBroadcaster — nil when disabled)
└─ holds: orch.AgentReporter (orchReporter = Broadcaster or Noop)
├─ passes to → SubagentManager.reporter
│ └─ passes to → ToolLoopConfig.Reporter
└─ calls directly for main/heartbeat sessions
├─ runAgentLoop: ReportSpawn / ReportGC
└─ runLLMIteration: ReportStateChange
cmd_gateway.go
└─ agentLoop.GetOrchBroadcaster() → handler.SetOrchBroadcaster()
miniapp.Handler
└─ borrows *orch.Broadcaster for Subscribe/Snapshot (WS 配信)
インターフェース (pkg/orch/reporter.go)
type AgentReporter interface {
ReportSpawn(id, label, task string)
ReportStateChange(id, state, tool string)
ReportConversation(from, to, text string)
ReportGC(id, reason string)
}
var Noop AgentReporter = &noopReporter{} // nil-free; 全メソッドが no-op
Broadcaster は AgentReporter を満たす (ReportSpawn 等が Publish のラッパー)。
Noop パターン
--orchestration なし: orchReporter = orch.Noop → 全 Report* が空振り (panic なし)
--orchestration あり: orchReporter = *Broadcaster → WS 配信
呼び出し側は if reporter != nil チェック不要。
イベント発火の責任分担
| 発火元 | イベント | 経由 |
|---|---|---|
runAgentLoop |
ReportSpawn / ReportGC |
al.reporter() |
runLLMIteration |
ReportStateChange("waiting"/"toolcall") |
al.reporter() |
SubagentManager.Spawn |
ReportSpawn |
sm.reporter |
SubagentManager.runTask |
ReportConversation / ReportGC |
sm.reporter |
RunToolLoop |
ReportStateChange |
config.Reporter |
main / heartbeat / subagent の全セッションが同一 Broadcaster に発火するため、 canvas には全エージェントが統一して表示される。
変更ファイル
pkg/orch/reporter.go— 新規 インターフェース + Nooppkg/orch/broadcaster.go—ReportSpawn/StateChange/Conversation/GC追加pkg/tools/toolloop.go—OnStateChange func→Reporter AgentReporter + AgentIDpkg/tools/subagent.go— constructor にreporter受け取り、内部 broadcaster 廃止、GetBroadcaster()削除pkg/agent/loop.go—orchBroadcaster/orchReporterフィールド追加、SetOrchReporter/GetOrchBroadcaster追加、registerSharedToolsシグネチャにal *AgentLoop追加cmd/picoclaw/cmd_gateway.go—GetOrchBroadcaster()→handler.SetOrchBroadcaster()
Memory Optimization Notes
Reviewed 2026-02-24 on branch
memory-optimization-review.
設計レベルの根本原因
D-1. MemoryStore が「ファイル = 正」でパース済み表現をキャッシュできない
GetMemoryContext() 1回で ReadLongTerm() が5回以上呼ばれる連鎖。MEMORY.md を外部エディタが直接編集できる設計上、インメモリキャッシュを自然に導入できない。
対策: (a) content パススルー方式 — 高レベルメソッドだけが1回 ReadLongTerm() を呼び、content を private ヘルパーに渡す。(b) *ParsedPlan 常駐 — RAM が潤沢なので MemoryStore にパース済み構造体を持たせる。edit_file 後に InvalidateCache() を呼ぶ。
D-2. FunctionCall.Arguments が JSON 文字列のままドメイン型に
ストリーミングループ内の重複 Unmarshal の根本原因。ToolCall.Arguments map[string]any のパース済みフィールドも存在するが中途半端に共存している。
D-3. ToolFunctionDefinition.Parameters が map[string]any
プロバイダーへ送るたびに Marshal が必要。json.RawMessage にすれば一度の marshal で済む。
D-4. 検索プロバイダーに共通フォーマット抽象がない
[]string + strings.Join パターンが3箇所に複製。Search() 戻り値を string でなく構造体にすれば1箇所で済む。
D-5. Session.Messages が可変スライスで全コピーが必要
GetHistory() / Save() での防衛的コピーは意図的設計。COW または append-only immutable 構造で解消できる。
D-6. MemoryStore のメソッド境界が「ファイル操作単位」
呼び出し側は複数の値が必要でも複数回呼ぶしかない。D-1 の解決策 (ParsedPlan 常駐) と合わせて解消。
コードの匂い — チェックリスト
新しいコードを書くとき・レビューするときの確認事項:
[]string+strings.Join→strings.Builderに一本化[]byte → string → io.Reader→bytes.NewReader(b)を直接使用- ループ内で静的なものを毎回生成 → ループ外で1回生成してキャッシュ
- 全件コピーが呼び出し側の用途より広い → COW または RWMutex + ポインタ返却を検討
- 同じ content を複数関数が独立して Split → 呼び出し側で1回 Split して渡す
var x []Tから始まる容量なし append → ソース長が既知ならmake([]T, 0, n)[]rune(s)変換前に長さチェックなし →len(s) <= maxで ASCII fast path を先に
ストレージ保護設計 (microSD 寿命)
| データ | 現状 | 推奨戦略 | 削減率 |
|---|---|---|---|
sessions/*.json |
メッセージ毎書き込み | write-behind (dirty flag + 5分タイマー) | 80% |
state/stats.json |
LLM呼び出し毎 | 定期フラッシュのみ (5分) | 98% |
memory/MEMORY.md |
即時 (変えない) | ターンスコープキャッシュ (読み取りのみ最適化) | — |
実装ポイント:
SessionManagerにdirtyKeys map[string]bool+ バックグラウンドフラッシャー goroutinestats.TrackerにClose()メソッド追加 (タイマー停止 + 最終 save)- SIGTERM/SIGINT でシャットダウンフック必須
- MEMORY.md の edit_file 書き込み後にターンキャッシュを無効化 (
InvalidateCache())
Session Management (Future)
現状の設計は「正確性」は成熟しているが「ライフサイクル」が欠落している。
近期:
SessionManager.Delete(key)+ TTL エビクションsessionLocks sync.Map(loop.go) の GC- 起動時の
loadSessions()を遅延ロード化
中期:
- チェックポイント / ロールバック (
Session.Messagesを append-only immutable に) - 名前付きセッション (
/new-session,/switch-session,/list-sessions)
長期:
Session.ParentKeyでサブエージェントセッションをグラフ化- クロスセッション検索 (MEMORY.md の補完として)
設計の哲学: Session.Messages (履歴中心) と MEMORY.md (知識中心) が現状共存している。どちらを主軸にするかで発展方向が変わる。