diff --git a/README.md b/README.md index ec70a52c5..bfc65ccfe 100644 --- a/README.md +++ b/README.md @@ -1012,6 +1012,7 @@ PicoClaw provides typed lifecycle hooks for observability, outbound filtering, a - If hooks are not set, default behavior is unchanged. See runnable examples: [docs/hooks-plugin-examples.md](docs/hooks-plugin-examples.md) + Roadmap for plugin system evolution: [docs/design/plugin-system-roadmap.md](docs/design/plugin-system-roadmap.md)
diff --git a/docs/design/plugin-system-roadmap.md b/docs/design/plugin-system-roadmap.md index 8981f5b0d..2709012db 100644 --- a/docs/design/plugin-system-roadmap.md +++ b/docs/design/plugin-system-roadmap.md @@ -4,7 +4,7 @@ This document defines how PicoClaw evolves from hook-based extension points to a ## Current Status (Phase 0: Foundation) -Implemented in current hooks MR: +Implemented in current hooks PR: - Typed lifecycle hooks (`pkg/hooks`) - Priority-based handler ordering @@ -27,19 +27,20 @@ Compatibility: ## Phase Plan -## Phase 1: Static Plugin Contract (Compile-time) +## Phase 1: Static Plugin Contract (Compile-time) — Implemented Goal: define a minimal public plugin contract for Go modules. -Proposed: +Implemented: - Add `pkg/plugin` with a small interface: - `Name() string` + - `APIVersion() string` - `Register(*hooks.HookRegistry) error` - Register plugins at startup in code. -- Add compatibility metadata (`PluginAPIVersion`) for forward checks. +- Add compatibility metadata (`plugin.APIVersion`) and registration-time checks. -Exit criteria: +Exit criteria (met): - Example plugin module builds against the contract. - Startup validation logs loaded plugins and registration errors clearly. @@ -104,4 +105,4 @@ Until then, compile-time registration remains the recommended model. ## Maintainer Review Notes -The current hooks MR should be reviewed as Phase 0 only. It intentionally establishes extension points while avoiding high-risk runtime plugin mechanics. +The current hooks PR should be reviewed as Phase 0+1 only. It intentionally establishes extension points while avoiding high-risk runtime plugin mechanics. diff --git a/docs/hooks-plugin-examples.md b/docs/hooks-plugin-examples.md index 230fb8a93..af7e500a2 100644 --- a/docs/hooks-plugin-examples.md +++ b/docs/hooks-plugin-examples.md @@ -50,6 +50,10 @@ Inbound message -> session_end ``` +Note: the map above is shown as a single pass for readability. In practice, the +agent loop may iterate up to `MaxToolIterations`, and `llm_input`, `llm_output`, +`before_tool_call`, and `after_tool_call` can fire multiple times. + ## Available Hooks | Hook | Type | Typical use | diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0e8b9cce1..9bd9145bb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -913,7 +913,7 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st } // maybeSummarize triggers summarization if the session history exceeds thresholds. -func (al *AgentLoop) maybeSummarize(ctx context.Context, agent *AgentInstance, sessionKey, channel, chatID string) { +func (al *AgentLoop) maybeSummarize(_ context.Context, agent *AgentInstance, sessionKey, channel, chatID string) { newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) threshold := agent.ContextWindow * 75 / 100 @@ -924,7 +924,7 @@ func (al *AgentLoop) maybeSummarize(ctx context.Context, agent *AgentInstance, s go func() { defer al.summarizing.Delete(summarizeKey) if !constants.IsInternalChannel(channel) { - al.sendOutbound(ctx, bus.OutboundMessage{ + al.sendOutbound(context.Background(), bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: "Memory threshold reached. Optimizing conversation history...", diff --git a/pkg/plugin/demoplugin/policy_demo.go b/pkg/plugin/demoplugin/policy_demo.go index 1b0f56572..2f1e4b47d 100644 --- a/pkg/plugin/demoplugin/policy_demo.go +++ b/pkg/plugin/demoplugin/policy_demo.go @@ -218,6 +218,8 @@ func clampArgNumber(args map[string]any, key string, max int) { func toInt(v any) (int, bool) { maxInt := int(^uint(0) >> 1) maxIntU64 := uint64(maxInt) + maxInt64 := int64(maxInt) + minInt64 := -maxInt64 - 1 switch n := v.(type) { case int: @@ -229,6 +231,9 @@ func toInt(v any) (int, bool) { case int32: return int(n), true case int64: + if n < minInt64 || n > maxInt64 { + return 0, false + } return int(n), true case uint: if uint64(n) > maxIntU64 { @@ -250,8 +255,10 @@ func toInt(v any) (int, bool) { } return int(n), true case float32: + // Truncation is intentional for timeout normalization. return int(n), true case float64: + // Truncation is intentional for timeout normalization. return int(n), true default: return 0, false diff --git a/pkg/plugin/demoplugin/policy_demo_test.go b/pkg/plugin/demoplugin/policy_demo_test.go index b2d9f58cb..825c673f0 100644 --- a/pkg/plugin/demoplugin/policy_demo_test.go +++ b/pkg/plugin/demoplugin/policy_demo_test.go @@ -2,6 +2,7 @@ package demoplugin import ( "context" + "strconv" "testing" "time" @@ -171,3 +172,12 @@ func TestPolicyDemoPluginNoConfigNoEffect(t *testing.T) { t.Fatalf("did not expect content rewrite, got %q", msgEvent.Content) } } + +func TestToIntRejectsInt64OverflowOn32Bit(t *testing.T) { + if strconv.IntSize != 32 { + t.Skip("overflow scenario is specific to 32-bit int") + } + if _, ok := toInt(int64(1 << 40)); ok { + t.Fatal("expected overflow conversion to fail on 32-bit int") + } +}