mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(agent): Agent Looper refactor phase2, restructure pipeline and rename loop files to agent (#2585)
* refactor(agent): introduce interfaces for MessageBus and ChannelManager Phase 2 of loop.go refactor — dependency inversion using adapter pattern. - Add interfaces.MessageBus and interfaces.ChannelManager interfaces - Create adapters/messagebus.go wrapping *bus.MessageBus - Create adapters/channelmanager.go wrapping *channels.Manager - Update AgentLoop to use interfaces instead of concrete types - Update registerSharedTools to accept interfaces.MessageBus Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(agent): restructure pipeline and rename loop files Pipeline refactoring: - Split pipeline.go (1400 lines) into focused files: - pipeline_setup.go (~115 lines): SetupTurn method - pipeline_llm.go (~519 lines): CallLLM method - pipeline_execute.go (~693 lines): ExecuteTools method - pipeline_finalize.go (~78 lines): Finalize method - Pipeline struct and NewPipeline remain in pipeline.go (~39 lines) Agent file renaming: - Rename loop_*.go to agent_*.go for consistent naming: - loop.go -> agent.go, loop_message.go -> agent_message.go, etc. - Merge turn.go + turn_exec.go into turn_state.go - Rename loop_turn.go -> turn_coord.go Documentation: - Update docs/pipeline-restructuring-plan.md - Add docs/agent-rename-plan.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(agent): code format fixed * refactor(agent): code test file added/renamed * docs(agent): update agent refactor docs * fix(agent): fix agent hardAbortX --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/commands"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
func outboundContextFromInbound(
|
||||
inbound *bus.InboundContext,
|
||||
channel, chatID, replyToMessageID string,
|
||||
) bus.InboundContext {
|
||||
if inbound == nil {
|
||||
return bus.NewOutboundContext(channel, chatID, replyToMessageID)
|
||||
}
|
||||
|
||||
outboundCtx := *cloneInboundContext(inbound)
|
||||
if outboundCtx.Channel == "" {
|
||||
outboundCtx.Channel = channel
|
||||
}
|
||||
if outboundCtx.ChatID == "" {
|
||||
outboundCtx.ChatID = chatID
|
||||
}
|
||||
if outboundCtx.ReplyToMessageID == "" {
|
||||
outboundCtx.ReplyToMessageID = replyToMessageID
|
||||
}
|
||||
return outboundCtx
|
||||
}
|
||||
|
||||
func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope {
|
||||
if scope == nil {
|
||||
return nil
|
||||
}
|
||||
outboundScope := &bus.OutboundScope{
|
||||
Version: scope.Version,
|
||||
AgentID: scope.AgentID,
|
||||
Channel: scope.Channel,
|
||||
Account: scope.Account,
|
||||
}
|
||||
if len(scope.Dimensions) > 0 {
|
||||
outboundScope.Dimensions = append([]string(nil), scope.Dimensions...)
|
||||
}
|
||||
if len(scope.Values) > 0 {
|
||||
outboundScope.Values = make(map[string]string, len(scope.Values))
|
||||
for key, value := range scope.Values {
|
||||
outboundScope.Values[key] = value
|
||||
}
|
||||
}
|
||||
return outboundScope
|
||||
}
|
||||
|
||||
func outboundTurnMetadata(
|
||||
agentID, sessionKey string,
|
||||
scope *session.SessionScope,
|
||||
) (string, string, *bus.OutboundScope) {
|
||||
return agentID, sessionKey, outboundScopeFromSessionScope(scope)
|
||||
}
|
||||
|
||||
func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage {
|
||||
agentID, sessionKey, scope := outboundTurnMetadata(ts.agent.ID, ts.sessionKey, ts.opts.Dispatch.SessionScope)
|
||||
return bus.OutboundMessage{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
Context: outboundContextFromInbound(
|
||||
ts.opts.Dispatch.InboundContext,
|
||||
ts.channel,
|
||||
ts.chatID,
|
||||
ts.opts.Dispatch.ReplyToMessageID(),
|
||||
),
|
||||
AgentID: agentID,
|
||||
SessionKey: sessionKey,
|
||||
Scope: scope,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneEventArguments(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
cloned[k] = v
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func hookDeniedToolContent(prefix, reason string) string {
|
||||
if reason == "" {
|
||||
return prefix
|
||||
}
|
||||
return prefix + ": " + reason
|
||||
}
|
||||
|
||||
func appendEventContextFields(fields map[string]any, turnCtx *TurnContext) {
|
||||
if turnCtx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if inbound := turnCtx.Inbound; inbound != nil {
|
||||
if inbound.Channel != "" {
|
||||
fields["inbound_channel"] = inbound.Channel
|
||||
}
|
||||
if inbound.Account != "" {
|
||||
fields["inbound_account"] = inbound.Account
|
||||
}
|
||||
if inbound.ChatID != "" {
|
||||
fields["inbound_chat_id"] = inbound.ChatID
|
||||
}
|
||||
if inbound.ChatType != "" {
|
||||
fields["inbound_chat_type"] = inbound.ChatType
|
||||
}
|
||||
if inbound.TopicID != "" {
|
||||
fields["inbound_topic_id"] = inbound.TopicID
|
||||
}
|
||||
if inbound.SpaceType != "" {
|
||||
fields["inbound_space_type"] = inbound.SpaceType
|
||||
}
|
||||
if inbound.SpaceID != "" {
|
||||
fields["inbound_space_id"] = inbound.SpaceID
|
||||
}
|
||||
if inbound.SenderID != "" {
|
||||
fields["inbound_sender_id"] = inbound.SenderID
|
||||
}
|
||||
if inbound.Mentioned {
|
||||
fields["inbound_mentioned"] = true
|
||||
}
|
||||
}
|
||||
|
||||
if route := turnCtx.Route; route != nil {
|
||||
if route.AgentID != "" {
|
||||
fields["route_agent_id"] = route.AgentID
|
||||
}
|
||||
if route.Channel != "" {
|
||||
fields["route_channel"] = route.Channel
|
||||
}
|
||||
if route.AccountID != "" {
|
||||
fields["route_account_id"] = route.AccountID
|
||||
}
|
||||
if route.MatchedBy != "" {
|
||||
fields["route_matched_by"] = route.MatchedBy
|
||||
}
|
||||
if len(route.SessionPolicy.Dimensions) > 0 {
|
||||
fields["route_dimensions"] = strings.Join(route.SessionPolicy.Dimensions, ",")
|
||||
}
|
||||
if count := len(route.SessionPolicy.IdentityLinks); count > 0 {
|
||||
fields["route_identity_link_count"] = count
|
||||
}
|
||||
}
|
||||
|
||||
if scope := turnCtx.Scope; scope != nil {
|
||||
if scope.Version > 0 {
|
||||
fields["scope_version"] = scope.Version
|
||||
}
|
||||
if scope.AgentID != "" {
|
||||
fields["scope_agent_id"] = scope.AgentID
|
||||
}
|
||||
if scope.Channel != "" {
|
||||
fields["scope_channel"] = scope.Channel
|
||||
}
|
||||
if scope.Account != "" {
|
||||
fields["scope_account"] = scope.Account
|
||||
}
|
||||
if len(scope.Dimensions) > 0 {
|
||||
fields["scope_dimensions"] = strings.Join(scope.Dimensions, ",")
|
||||
}
|
||||
for dim, value := range scope.Values {
|
||||
if dim == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
fields["scope_"+dim] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func inferMediaType(filename, contentType string) string {
|
||||
ct := strings.ToLower(contentType)
|
||||
fn := strings.ToLower(filename)
|
||||
|
||||
if strings.HasPrefix(ct, "image/") {
|
||||
return "image"
|
||||
}
|
||||
if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
|
||||
return "audio"
|
||||
}
|
||||
if strings.HasPrefix(ct, "video/") {
|
||||
return "video"
|
||||
}
|
||||
|
||||
// Fallback: infer from extension
|
||||
ext := filepath.Ext(fn)
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
||||
return "image"
|
||||
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
||||
return "audio"
|
||||
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
||||
return "video"
|
||||
}
|
||||
|
||||
return "file"
|
||||
}
|
||||
|
||||
func normalizedInboundContext(msg bus.InboundMessage) bus.InboundContext {
|
||||
return bus.NormalizeInboundMessage(msg).Context
|
||||
}
|
||||
|
||||
func resolveScopeKey(routeSessionKey, msgSessionKey string) string {
|
||||
if isExplicitSessionKey(msgSessionKey) {
|
||||
return msgSessionKey
|
||||
}
|
||||
return routeSessionKey
|
||||
}
|
||||
|
||||
func isExplicitSessionKey(sessionKey string) bool {
|
||||
return session.IsExplicitSessionKey(sessionKey)
|
||||
}
|
||||
|
||||
func buildSessionAliases(canonicalKey string, keys ...string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
aliases := make([]string, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
canonicalKey = strings.TrimSpace(canonicalKey)
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || key == canonicalKey {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
aliases = append(aliases, key)
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) {
|
||||
if key == "" || scope == nil {
|
||||
return
|
||||
}
|
||||
metaStore, ok := store.(interface {
|
||||
EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string)
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
metaStore.EnsureSessionMetadata(key, scope, aliases)
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func formatMessagesForLog(messages []providers.Message) string {
|
||||
if len(messages) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[\n")
|
||||
for i, msg := range messages {
|
||||
fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
sb.WriteString(" ToolCalls:\n")
|
||||
for _, tc := range msg.ToolCalls {
|
||||
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
|
||||
if tc.Function != nil {
|
||||
fmt.Fprintf(
|
||||
&sb,
|
||||
" Arguments: %s\n",
|
||||
utils.Truncate(tc.Function.Arguments, 200),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.Content != "" {
|
||||
content := utils.Truncate(msg.Content, 200)
|
||||
fmt.Fprintf(&sb, " Content: %s\n", content)
|
||||
}
|
||||
if msg.ToolCallID != "" {
|
||||
fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
|
||||
if len(toolDefs) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[\n")
|
||||
for i, tool := range toolDefs {
|
||||
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
|
||||
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
|
||||
if len(tool.Function.Parameters) > 0 {
|
||||
fmt.Fprintf(
|
||||
&sb,
|
||||
" Parameters: %s\n",
|
||||
utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200),
|
||||
)
|
||||
}
|
||||
}
|
||||
sb.WriteString("]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
|
||||
if agent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills))
|
||||
combined = append(combined, agent.SkillsFilter...)
|
||||
combined = append(combined, opts.ForcedSkills...)
|
||||
if len(combined) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var resolved []string
|
||||
seen := make(map[string]struct{}, len(combined))
|
||||
for _, name := range combined {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if agent.ContextBuilder != nil {
|
||||
if canonical, ok := agent.ContextBuilder.ResolveSkillName(name); ok {
|
||||
name = canonical
|
||||
}
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
resolved = append(resolved, name)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
func sideQuestionResponseContent(response *providers.LLMResponse) string {
|
||||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
if response.Content != "" {
|
||||
return response.Content
|
||||
}
|
||||
return response.ReasoningContent
|
||||
}
|
||||
|
||||
func shallowCloneLLMOptions(opts map[string]any) map[string]any {
|
||||
clone := make(map[string]any, len(opts))
|
||||
for k, v := range opts {
|
||||
clone[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func hasMediaRefs(messages []providers.Message) bool {
|
||||
for _, msg := range messages {
|
||||
if len(msg.Media) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sideQuestionModelName(agent *AgentInstance, usedLight bool) string {
|
||||
if usedLight && len(agent.LightCandidates) > 0 {
|
||||
// Use the first light candidate's model
|
||||
return agent.LightCandidates[0].Model
|
||||
}
|
||||
return agent.Model
|
||||
}
|
||||
|
||||
func modelNameFromIdentityKey(identityKey string) string {
|
||||
if identityKey == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(identityKey, "/", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return identityKey
|
||||
}
|
||||
|
||||
func closeProviderIfStateful(provider providers.LLMProvider) {
|
||||
if stateful, ok := provider.(providers.StatefulProvider); ok {
|
||||
stateful.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func makePendingTurnID(sessionKey string, seq uint64) string {
|
||||
return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq)
|
||||
}
|
||||
|
||||
func commandsUnavailableSkillMessage() string {
|
||||
return "Skill selection is unavailable in the current context."
|
||||
}
|
||||
|
||||
func buildUseCommandHelp(agent *AgentInstance) string {
|
||||
if agent == nil || agent.ContextBuilder == nil {
|
||||
return "Usage: /use <skill> [message]"
|
||||
}
|
||||
|
||||
names := agent.ContextBuilder.ListSkillNames()
|
||||
if len(names) == 0 {
|
||||
return "Usage: /use <skill> [message]\nNo installed skills found."
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Usage: /use <skill> [message]\n\nInstalled Skills:\n- %s\n\nUse /use <skill> to apply a skill to your next message, or /use <skill> <message> to force it immediately.",
|
||||
strings.Join(names, "\n- "),
|
||||
)
|
||||
}
|
||||
|
||||
func mapCommandError(result commands.ExecuteResult) string {
|
||||
if result.Command == "" {
|
||||
return fmt.Sprintf("Failed to execute command: %v", result.Err)
|
||||
}
|
||||
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
|
||||
}
|
||||
|
||||
func isNativeSearchProvider(p providers.LLMProvider) bool {
|
||||
if ns, ok := p.(providers.NativeSearchCapable); ok {
|
||||
return ns.SupportsNativeSearch()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition {
|
||||
result := make([]providers.ToolDefinition, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
if strings.EqualFold(t.Function.Name, "web_search") {
|
||||
continue
|
||||
}
|
||||
result = append(result, t)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) {
|
||||
if registry == nil {
|
||||
return nil, false
|
||||
}
|
||||
// Get any agent to access the provider
|
||||
defaultAgent := registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
return nil, false
|
||||
}
|
||||
return defaultAgent.Provider, true
|
||||
}
|
||||
Reference in New Issue
Block a user