mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
329e68e017
* 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>
483 lines
12 KiB
Go
483 lines
12 KiB
Go
// 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
|
|
}
|