mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
resolve conflicts
This commit is contained in:
@@ -508,6 +508,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||
currentMessage string,
|
||||
media []string,
|
||||
channel, chatID, senderID, senderDisplayName string,
|
||||
activeSkills ...string,
|
||||
) []providers.Message {
|
||||
messages := []providers.Message{}
|
||||
|
||||
@@ -541,6 +542,11 @@ func (cb *ContextBuilder) BuildMessages(
|
||||
{Type: "text", Text: dynamicCtx},
|
||||
}
|
||||
|
||||
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
|
||||
stringParts = append(stringParts, skillsText)
|
||||
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText})
|
||||
}
|
||||
|
||||
if summary != "" {
|
||||
summaryText := fmt.Sprintf(
|
||||
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
||||
@@ -748,6 +754,68 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
||||
return messages
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
||||
if cb.skillsLoader == nil || len(skillNames) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var ordered []string
|
||||
seen := make(map[string]struct{}, len(skillNames))
|
||||
for _, name := range skillNames {
|
||||
canonical, ok := cb.ResolveSkillName(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[canonical]; exists {
|
||||
continue
|
||||
}
|
||||
seen[canonical] = struct{}{}
|
||||
ordered = append(ordered, canonical)
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := cb.skillsLoader.LoadSkillsForContext(ordered)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`# Active Skills
|
||||
|
||||
The following skills are active for this request. Follow them when relevant.
|
||||
|
||||
%s`, content)
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) ListSkillNames() []string {
|
||||
if cb.skillsLoader == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
names := make([]string, 0, len(allSkills))
|
||||
for _, skill := range allSkills {
|
||||
names = append(names, skill.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) ResolveSkillName(name string) (string, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || cb.skillsLoader == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, skill := range cb.skillsLoader.ListSkills() {
|
||||
if strings.EqualFold(skill.Name, name) {
|
||||
return skill.Name, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetSkillsInfo returns information about loaded skills.
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
|
||||
+214
-128
@@ -56,6 +56,7 @@ type AgentLoop struct {
|
||||
mcp mcpRuntime
|
||||
hookRuntime hookRuntime
|
||||
steering *steeringQueue
|
||||
pendingSkills sync.Map
|
||||
mu sync.RWMutex
|
||||
|
||||
// Concurrent turn management (from HEAD)
|
||||
@@ -77,6 +78,7 @@ type processOptions struct {
|
||||
SenderID string // Current sender ID for dynamic context
|
||||
SenderDisplayName string // Current sender display name for dynamic context
|
||||
UserMessage string // User message content (may include prefix)
|
||||
ForcedSkills []string // Skills explicitly requested for this message
|
||||
SystemPromptOverride string // Override the default system prompt (Used by SubTurns)
|
||||
Media []string // media:// refs from inbound message
|
||||
InitialSteeringMessages []providers.Message // Steering messages from refactor/agent
|
||||
@@ -1310,6 +1312,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
if pending := al.takePendingSkills(opts.SessionKey); len(pending) > 0 {
|
||||
opts.ForcedSkills = append(opts.ForcedSkills, pending...)
|
||||
logger.InfoCF("agent", "Applying pending skill override",
|
||||
map[string]any{
|
||||
"session_key": opts.SessionKey,
|
||||
"skills": strings.Join(pending, ","),
|
||||
})
|
||||
}
|
||||
|
||||
return al.runAgentLoop(ctx, agent, opts)
|
||||
}
|
||||
|
||||
@@ -1454,16 +1465,6 @@ func (al *AgentLoop) runAgentLoop(
|
||||
|
||||
ts := newTurnState(agent, opts, al.newTurnEventScope(agent.ID, opts.SessionKey))
|
||||
result, err := al.runTurn(ctx, ts)
|
||||
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
|
||||
cfg := al.GetConfig()
|
||||
maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()
|
||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||
|
||||
// 2. Save user message to session
|
||||
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||
|
||||
// 3. Run LLM iteration loop
|
||||
finalContent, iteration, responseHandled, err := al.runLLMIteration(ctx, agent, messages, opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1471,22 +1472,6 @@ func (al *AgentLoop) runAgentLoop(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if responseHandled {
|
||||
agent.Sessions.Save(opts.SessionKey)
|
||||
|
||||
if opts.EnableSummary {
|
||||
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
||||
}
|
||||
|
||||
logger.InfoCF("agent", "Response already handled by tool output",
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"session_key": opts.SessionKey,
|
||||
"iterations": iteration,
|
||||
})
|
||||
return "", nil
|
||||
}
|
||||
|
||||
for _, followUp := range result.followUps {
|
||||
if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil {
|
||||
logger.WarnCF("agent", "Failed to publish follow-up after turn",
|
||||
@@ -1575,8 +1560,6 @@ func (al *AgentLoop) handleReasoning(
|
||||
}
|
||||
}
|
||||
|
||||
const handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||
|
||||
func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, error) {
|
||||
turnCtx, turnCancel := context.WithCancel(ctx)
|
||||
defer turnCancel()
|
||||
@@ -1631,6 +1614,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||
ts.chatID,
|
||||
ts.opts.SenderID,
|
||||
ts.opts.SenderDisplayName,
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
)
|
||||
|
||||
cfg := al.GetConfig()
|
||||
@@ -1660,6 +1644,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||
newHistory, newSummary, ts.userMessage,
|
||||
ts.media, ts.channel, ts.chatID,
|
||||
ts.opts.SenderID, ts.opts.SenderDisplayName,
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
)
|
||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||
}
|
||||
@@ -1682,59 +1667,8 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||
|
||||
activeCandidates, activeModel := al.selectCandidates(ts.agent, ts.userMessage, messages)
|
||||
pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...)
|
||||
const handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||
|
||||
func (al *AgentLoop) buildOutboundMediaMessage(
|
||||
channel string,
|
||||
chatID string,
|
||||
refs []string,
|
||||
) bus.OutboundMediaMessage {
|
||||
parts := make([]bus.MediaPart, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
part := bus.MediaPart{Ref: ref}
|
||||
if al.mediaStore != nil {
|
||||
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
||||
part.Filename = meta.Filename
|
||||
part.ContentType = meta.ContentType
|
||||
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
||||
}
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return bus.OutboundMediaMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Parts: parts,
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) buildArtifactTags(refs []string) []string {
|
||||
if al.mediaStore == nil || len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
localPath, meta, err := al.mediaStore.ResolveWithMeta(ref)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mime := detectMIME(localPath, meta)
|
||||
tags = append(tags, buildPathTag(mime, localPath))
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// runLLMIteration executes the LLM call loop with tool handling.
|
||||
// Returns (finalContent, iteration, responseHandled, error).
|
||||
func (al *AgentLoop) runLLMIteration(
|
||||
ctx context.Context,
|
||||
agent *AgentInstance,
|
||||
messages []providers.Message,
|
||||
opts processOptions,
|
||||
) (string, int, bool, error) {
|
||||
iteration := 0
|
||||
var finalContent string
|
||||
const handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||
|
||||
turnLoop:
|
||||
for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 || func() bool {
|
||||
@@ -2078,6 +2012,7 @@ turnLoop:
|
||||
newHistory, newSummary, "",
|
||||
nil, ts.channel, ts.chatID,
|
||||
"", "", // Empty SenderID and SenderDisplayName for retry
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
)
|
||||
callMessages = messages
|
||||
if gracefulTerminal {
|
||||
@@ -2138,7 +2073,6 @@ turnLoop:
|
||||
if response.Usage != nil {
|
||||
innerTS.SetLastUsage(response.Usage)
|
||||
}
|
||||
return "", iteration, false, fmt.Errorf("LLM call failed after retries: %w", err)
|
||||
}
|
||||
|
||||
go al.handleReasoning(
|
||||
@@ -2189,7 +2123,6 @@ turnLoop:
|
||||
"agent_id": ts.agent.ID,
|
||||
"iteration": iteration,
|
||||
"content_chars": len(finalContent),
|
||||
"streamed": streamer != nil,
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -2211,6 +2144,7 @@ turnLoop:
|
||||
"iteration": iteration,
|
||||
})
|
||||
|
||||
allResponsesHandled := len(normalizedToolCalls) > 0
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
@@ -2460,18 +2394,11 @@ turnLoop:
|
||||
if toolResult == nil {
|
||||
toolResult = tools.ErrorResult("hook returned nil tool result")
|
||||
}
|
||||
|
||||
if !toolResult.Silent && toolResult.ForUser != "" && ts.opts.SendResponse {
|
||||
allResponsesHandled := len(agentResults) > 0
|
||||
|
||||
// Process results in original order (send to user, save to session)
|
||||
for _, r := range agentResults {
|
||||
if !r.result.ResponseHandled {
|
||||
if !toolResult.ResponseHandled {
|
||||
allResponsesHandled = false
|
||||
}
|
||||
|
||||
// Send ForUser content to user immediately if not Silent
|
||||
if !r.result.Silent && r.result.ForUser != "" && opts.SendResponse {
|
||||
if !toolResult.Silent && toolResult.ForUser != "" && ts.opts.SendResponse {
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
@@ -2493,24 +2420,7 @@ turnLoop:
|
||||
part.Filename = meta.Filename
|
||||
part.ContentType = meta.ContentType
|
||||
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
||||
// If tool returned media refs, publish them as outbound media only when the
|
||||
// tool explicitly marked the user-visible delivery as already handled.
|
||||
if len(r.result.Media) > 0 {
|
||||
outboundMedia := al.buildOutboundMediaMessage(opts.Channel, opts.ChatID, r.result.Media)
|
||||
if r.result.ResponseHandled {
|
||||
if al.channelManager != nil {
|
||||
if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil {
|
||||
allResponsesHandled = false
|
||||
logger.WarnCF("agent", "Synchronous media send failed, falling back to bus delivery",
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"tool": r.tc.Name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
al.bus.PublishOutboundMedia(ctx, outboundMedia)
|
||||
}
|
||||
} else {
|
||||
al.bus.PublishOutboundMedia(ctx, outboundMedia)
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
@@ -2521,14 +2431,10 @@ turnLoop:
|
||||
})
|
||||
}
|
||||
|
||||
contentForLLM := toolResult.ForLLM
|
||||
if contentForLLM == "" && toolResult.Err != nil {
|
||||
contentForLLM = toolResult.Err.Error()
|
||||
// Determine content for LLM based on tool result
|
||||
if len(r.result.Media) > 0 && !r.result.ResponseHandled {
|
||||
r.result.ArtifactTags = al.buildArtifactTags(r.result.Media)
|
||||
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
||||
toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media)
|
||||
}
|
||||
contentForLLM := r.result.ContentForLLM()
|
||||
contentForLLM := toolResult.ContentForLLM()
|
||||
|
||||
toolResultMsg := providers.Message{
|
||||
Role: "tool",
|
||||
@@ -2617,31 +2523,48 @@ turnLoop:
|
||||
}
|
||||
}
|
||||
|
||||
ts.agent.Tools.TickTTL()
|
||||
if allResponsesHandled {
|
||||
summaryMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: handledToolResponseSummary,
|
||||
}
|
||||
messages = append(messages, summaryMsg)
|
||||
agent.Sessions.AddFullMessage(opts.SessionKey, summaryMsg)
|
||||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content)
|
||||
ts.recordPersistedMessage(summaryMsg)
|
||||
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
|
||||
turnStatus = TurnEndStatusError
|
||||
al.emitEvent(
|
||||
EventKindError,
|
||||
ts.eventMeta("runTurn", "turn.error"),
|
||||
ErrorPayload{
|
||||
Stage: "session_save",
|
||||
Message: err.Error(),
|
||||
},
|
||||
)
|
||||
return turnResult{}, err
|
||||
}
|
||||
}
|
||||
if ts.opts.EnableSummary {
|
||||
al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope)
|
||||
}
|
||||
|
||||
ts.setPhase(TurnPhaseCompleted)
|
||||
ts.setFinalContent("")
|
||||
logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM",
|
||||
map[string]any{
|
||||
"agent_id": agent.ID,
|
||||
"agent_id": ts.agent.ID,
|
||||
"iteration": iteration,
|
||||
"tool_count": len(agentResults),
|
||||
"tool_count": len(normalizedToolCalls),
|
||||
})
|
||||
return "", iteration, true, nil
|
||||
return turnResult{
|
||||
finalContent: "",
|
||||
status: turnStatus,
|
||||
followUps: append([]bus.InboundMessage(nil), ts.followUps...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Tick down TTL of discovered tools after processing tool results.
|
||||
// Only reached when tool calls were made (the loop continues);
|
||||
// the break on no-tool-call responses skips this.
|
||||
// NOTE: This is safe because processMessage is sequential per agent.
|
||||
// If per-agent concurrency is added, TTL consistency between
|
||||
// ToProviderDefs and Get must be re-evaluated.
|
||||
agent.Tools.TickTTL()
|
||||
ts.agent.Tools.TickTTL()
|
||||
logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{
|
||||
"agent_id": ts.agent.ID, "iteration": iteration,
|
||||
})
|
||||
@@ -2664,7 +2587,6 @@ turnLoop:
|
||||
return al.abortTurn(ts)
|
||||
}
|
||||
|
||||
return finalContent, iteration, false, nil
|
||||
if finalContent == "" {
|
||||
if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 {
|
||||
finalContent = toolLimitResponse
|
||||
@@ -3212,6 +3134,10 @@ func (al *AgentLoop) handleCommand(
|
||||
return "", false
|
||||
}
|
||||
|
||||
if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched {
|
||||
return reply, handled
|
||||
}
|
||||
|
||||
if al.cmdRegistry == nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -3245,6 +3171,97 @@ func (al *AgentLoop) handleCommand(
|
||||
}
|
||||
}
|
||||
|
||||
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 (al *AgentLoop) applyExplicitSkillCommand(
|
||||
raw string,
|
||||
agent *AgentInstance,
|
||||
opts *processOptions,
|
||||
) (matched bool, handled bool, reply string) {
|
||||
cmdName, ok := commands.CommandName(raw)
|
||||
if !ok || cmdName != "use" {
|
||||
return false, false, ""
|
||||
}
|
||||
|
||||
if agent == nil || agent.ContextBuilder == nil {
|
||||
return true, true, commandsUnavailableSkillMessage()
|
||||
}
|
||||
|
||||
parts := strings.Fields(strings.TrimSpace(raw))
|
||||
if len(parts) < 2 {
|
||||
return true, true, buildUseCommandHelp(agent)
|
||||
}
|
||||
|
||||
arg := strings.TrimSpace(parts[1])
|
||||
if strings.EqualFold(arg, "clear") || strings.EqualFold(arg, "off") {
|
||||
if opts != nil {
|
||||
al.clearPendingSkills(opts.SessionKey)
|
||||
}
|
||||
return true, true, "Cleared pending skill override."
|
||||
}
|
||||
|
||||
skillName, ok := agent.ContextBuilder.ResolveSkillName(arg)
|
||||
if !ok {
|
||||
return true, true, fmt.Sprintf("Unknown skill %q.\n\n%s", arg, buildUseCommandHelp(agent))
|
||||
}
|
||||
|
||||
if len(parts) < 3 {
|
||||
if opts == nil || strings.TrimSpace(opts.SessionKey) == "" {
|
||||
return true, true, commandsUnavailableSkillMessage()
|
||||
}
|
||||
al.setPendingSkills(opts.SessionKey, []string{skillName})
|
||||
return true, true, fmt.Sprintf(
|
||||
"Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.",
|
||||
skillName,
|
||||
)
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(strings.Join(parts[2:], " "))
|
||||
if message == "" {
|
||||
return true, true, buildUseCommandHelp(agent)
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
opts.ForcedSkills = append(opts.ForcedSkills, skillName)
|
||||
opts.UserMessage = message
|
||||
}
|
||||
|
||||
return true, false, ""
|
||||
}
|
||||
|
||||
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
|
||||
registry := al.GetRegistry()
|
||||
cfg := al.GetConfig()
|
||||
@@ -3282,6 +3299,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
||||
return al.reloadFunc()
|
||||
}
|
||||
if agent != nil {
|
||||
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
||||
rt.GetModelInfo = func() (string, string) {
|
||||
return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider)
|
||||
}
|
||||
@@ -3334,6 +3352,74 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
||||
return rt
|
||||
}
|
||||
|
||||
func commandsUnavailableSkillMessage() string {
|
||||
return "Skill commands are unavailable in the current context."
|
||||
}
|
||||
|
||||
func buildUseCommandHelp(agent *AgentInstance) string {
|
||||
usage := "Usage:\n/use <skill> <message>\n/use <skill>\n/use clear"
|
||||
if agent == nil || agent.ContextBuilder == nil {
|
||||
return usage
|
||||
}
|
||||
|
||||
names := agent.ContextBuilder.ListSkillNames()
|
||||
if len(names) == 0 {
|
||||
return "No installed skills.\n\n" + usage
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s\n\nInstalled Skills:\n- %s", usage, strings.Join(names, "\n- "))
|
||||
}
|
||||
|
||||
func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" || len(skillNames) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(skillNames))
|
||||
for _, name := range skillNames {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, name)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
al.pendingSkills.Store(sessionKey, values)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) takePendingSkills(sessionKey string) []string {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
value, ok := al.pendingSkills.LoadAndDelete(sessionKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
skills, ok := value.([]string)
|
||||
if !ok || len(skills) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, len(skills))
|
||||
copy(out, skills)
|
||||
return out
|
||||
}
|
||||
|
||||
func (al *AgentLoop) clearPendingSkills(sessionKey string) {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return
|
||||
}
|
||||
al.pendingSkills.Delete(sessionKey)
|
||||
}
|
||||
|
||||
func mapCommandError(result commands.ExecuteResult) string {
|
||||
if result.Command == "" {
|
||||
return fmt.Sprintf("Failed to execute command: %v", result.Err)
|
||||
|
||||
@@ -87,6 +87,24 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
|
||||
return result
|
||||
}
|
||||
|
||||
func buildArtifactTags(store media.MediaStore, refs []string) []string {
|
||||
if store == nil || len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mime := detectMIME(localPath, meta)
|
||||
tags = append(tags, buildPathTag(mime, localPath))
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// detectMIME determines the MIME type from metadata or magic-bytes detection.
|
||||
// Returns empty string if detection fails.
|
||||
func detectMIME(localPath string, meta media.MediaMeta) string {
|
||||
|
||||
+81
-1
@@ -132,6 +132,86 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExplicitSkillCommand_ArmsSkillForNextMessage(t *testing.T) {
|
||||
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(skill) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news", "SKILL.md"),
|
||||
[]byte("# Finance News\n\nUse web tools for current finance updates.\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
|
||||
}
|
||||
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
opts := &processOptions{SessionKey: "agent:main:test"}
|
||||
matched, handled, reply := al.applyExplicitSkillCommand("/use finance-news", agent, opts)
|
||||
if !matched {
|
||||
t.Fatal("expected /use command to match")
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("expected /use without inline message to be handled immediately")
|
||||
}
|
||||
if !strings.Contains(reply, `Skill "finance-news" is armed for your next message`) {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
|
||||
pending := al.takePendingSkills(opts.SessionKey)
|
||||
if len(pending) != 1 || pending[0] != "finance-news" {
|
||||
t.Fatalf("pending skills = %#v, want [finance-news]", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExplicitSkillCommand_InlineMessageMutatesOptions(t *testing.T) {
|
||||
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(skill) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(cfg.Agents.Defaults.Workspace, "skills", "finance-news", "SKILL.md"),
|
||||
[]byte("# Finance News\n\nUse web tools for current finance updates.\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
|
||||
}
|
||||
|
||||
agent := al.GetRegistry().GetDefaultAgent()
|
||||
if agent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
opts := &processOptions{
|
||||
SessionKey: "agent:main:test",
|
||||
UserMessage: "/use finance-news dammi le ultime news",
|
||||
}
|
||||
matched, handled, reply := al.applyExplicitSkillCommand(opts.UserMessage, agent, opts)
|
||||
if !matched {
|
||||
t.Fatal("expected /use command to match")
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("expected /use with inline message to fall through into normal agent execution")
|
||||
}
|
||||
if reply != "" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if opts.UserMessage != "dammi le ultime news" {
|
||||
t.Fatalf("opts.UserMessage = %q, want %q", opts.UserMessage, "dammi le ultime news")
|
||||
}
|
||||
if len(opts.ForcedSkills) != 1 || opts.ForcedSkills[0] != "finance-news" {
|
||||
t.Fatalf("opts.ForcedSkills = %#v, want [finance-news]", opts.ForcedSkills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordLastChannel(t *testing.T) {
|
||||
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
||||
defer cleanup()
|
||||
@@ -381,7 +461,7 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
|
||||
t.Fatal("expected session history to be saved")
|
||||
}
|
||||
last := history[len(history)-1]
|
||||
if last.Role != "assistant" || last.Content != handledToolResponseSummary {
|
||||
if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." {
|
||||
t.Fatalf("expected handled assistant summary in history, got %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition {
|
||||
helpCommand(),
|
||||
showCommand(),
|
||||
listCommand(),
|
||||
useCommand(),
|
||||
switchCommand(),
|
||||
checkCommand(),
|
||||
clearCommand(),
|
||||
|
||||
@@ -39,9 +39,12 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||
if !strings.Contains(reply, "/show [model|channel|agents]") {
|
||||
t.Fatalf("/help reply missing /show usage, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "/list [models|channels|agents]") {
|
||||
if !strings.Contains(reply, "/list [models|channels|agents|skills]") {
|
||||
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "/use <skill> [message]") {
|
||||
t.Fatalf("/help reply missing /use usage, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
|
||||
|
||||
@@ -47,6 +47,23 @@ func listCommand() Definition {
|
||||
Description: "Registered agents",
|
||||
Handler: agentsHandler(),
|
||||
},
|
||||
{
|
||||
Name: "skills",
|
||||
Description: "Installed skills",
|
||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil || rt.ListSkillNames == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
names := rt.ListSkillNames()
|
||||
if len(names) == 0 {
|
||||
return req.Reply("No installed skills")
|
||||
}
|
||||
return req.Reply(fmt.Sprintf(
|
||||
"Installed Skills:\n- %s\n\nUse /use <skill> <message> to force one for a single request, or /use <skill> to apply it to your next message.",
|
||||
strings.Join(names, "\n- "),
|
||||
))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package commands
|
||||
|
||||
func useCommand() Definition {
|
||||
return Definition{
|
||||
Name: "use",
|
||||
Description: "Force a specific installed skill for one request",
|
||||
Usage: "/use <skill> [message]",
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ func parseCommandName(input string) (string, bool) {
|
||||
return name, true
|
||||
}
|
||||
|
||||
// CommandName returns the normalized command name for an input if present.
|
||||
func CommandName(input string) (string, bool) {
|
||||
return parseCommandName(input)
|
||||
}
|
||||
|
||||
func trimCommandPrefix(token string) (string, bool) {
|
||||
for _, prefix := range commandPrefixes {
|
||||
if strings.HasPrefix(token, prefix) {
|
||||
|
||||
@@ -10,6 +10,7 @@ type Runtime struct {
|
||||
GetModelInfo func() (name, provider string)
|
||||
ListAgentIDs func() []string
|
||||
ListDefinitions func() []Definition
|
||||
ListSkillNames func() []string
|
||||
GetEnabledChannels func() []string
|
||||
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
||||
SwitchModel func(value string) (oldModel string, err error)
|
||||
|
||||
Reference in New Issue
Block a user