resolve conflicts
This commit is contained in:
parent
14a4983af3
commit
b90c5007f6
10 changed files with 418 additions and 130 deletions
|
|
@ -508,6 +508,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
currentMessage string,
|
currentMessage string,
|
||||||
media []string,
|
media []string,
|
||||||
channel, chatID, senderID, senderDisplayName string,
|
channel, chatID, senderID, senderDisplayName string,
|
||||||
|
activeSkills ...string,
|
||||||
) []providers.Message {
|
) []providers.Message {
|
||||||
messages := []providers.Message{}
|
messages := []providers.Message{}
|
||||||
|
|
||||||
|
|
@ -541,6 +542,11 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
{Type: "text", Text: dynamicCtx},
|
{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 != "" {
|
if summary != "" {
|
||||||
summaryText := fmt.Sprintf(
|
summaryText := fmt.Sprintf(
|
||||||
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
||||||
|
|
@ -748,6 +754,68 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
||||||
return messages
|
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.
|
// GetSkillsInfo returns information about loaded skills.
|
||||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||||
allSkills := cb.skillsLoader.ListSkills()
|
allSkills := cb.skillsLoader.ListSkills()
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ type AgentLoop struct {
|
||||||
mcp mcpRuntime
|
mcp mcpRuntime
|
||||||
hookRuntime hookRuntime
|
hookRuntime hookRuntime
|
||||||
steering *steeringQueue
|
steering *steeringQueue
|
||||||
|
pendingSkills sync.Map
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
// Concurrent turn management (from HEAD)
|
// Concurrent turn management (from HEAD)
|
||||||
|
|
@ -77,6 +78,7 @@ type processOptions struct {
|
||||||
SenderID string // Current sender ID for dynamic context
|
SenderID string // Current sender ID for dynamic context
|
||||||
SenderDisplayName string // Current sender display name for dynamic context
|
SenderDisplayName string // Current sender display name for dynamic context
|
||||||
UserMessage string // User message content (may include prefix)
|
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)
|
SystemPromptOverride string // Override the default system prompt (Used by SubTurns)
|
||||||
Media []string // media:// refs from inbound message
|
Media []string // media:// refs from inbound message
|
||||||
InitialSteeringMessages []providers.Message // Steering messages from refactor/agent
|
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
|
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)
|
return al.runAgentLoop(ctx, agent, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1454,16 +1465,6 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
|
|
||||||
ts := newTurnState(agent, opts, al.newTurnEventScope(agent.ID, opts.SessionKey))
|
ts := newTurnState(agent, opts, al.newTurnEventScope(agent.ID, opts.SessionKey))
|
||||||
result, err := al.runTurn(ctx, ts)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -1471,22 +1472,6 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
return "", nil
|
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 {
|
for _, followUp := range result.followUps {
|
||||||
if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil {
|
if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil {
|
||||||
logger.WarnCF("agent", "Failed to publish follow-up after turn",
|
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) {
|
func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, error) {
|
||||||
turnCtx, turnCancel := context.WithCancel(ctx)
|
turnCtx, turnCancel := context.WithCancel(ctx)
|
||||||
defer turnCancel()
|
defer turnCancel()
|
||||||
|
|
@ -1631,6 +1614,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||||
ts.chatID,
|
ts.chatID,
|
||||||
ts.opts.SenderID,
|
ts.opts.SenderID,
|
||||||
ts.opts.SenderDisplayName,
|
ts.opts.SenderDisplayName,
|
||||||
|
activeSkillNames(ts.agent, ts.opts)...,
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg := al.GetConfig()
|
cfg := al.GetConfig()
|
||||||
|
|
@ -1660,6 +1644,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
|
||||||
newHistory, newSummary, ts.userMessage,
|
newHistory, newSummary, ts.userMessage,
|
||||||
ts.media, ts.channel, ts.chatID,
|
ts.media, ts.channel, ts.chatID,
|
||||||
ts.opts.SenderID, ts.opts.SenderDisplayName,
|
ts.opts.SenderID, ts.opts.SenderDisplayName,
|
||||||
|
activeSkillNames(ts.agent, ts.opts)...,
|
||||||
)
|
)
|
||||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
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)
|
activeCandidates, activeModel := al.selectCandidates(ts.agent, ts.userMessage, messages)
|
||||||
pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...)
|
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
|
var finalContent string
|
||||||
|
const handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||||
|
|
||||||
turnLoop:
|
turnLoop:
|
||||||
for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 || func() bool {
|
for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 || func() bool {
|
||||||
|
|
@ -2078,6 +2012,7 @@ turnLoop:
|
||||||
newHistory, newSummary, "",
|
newHistory, newSummary, "",
|
||||||
nil, ts.channel, ts.chatID,
|
nil, ts.channel, ts.chatID,
|
||||||
"", "", // Empty SenderID and SenderDisplayName for retry
|
"", "", // Empty SenderID and SenderDisplayName for retry
|
||||||
|
activeSkillNames(ts.agent, ts.opts)...,
|
||||||
)
|
)
|
||||||
callMessages = messages
|
callMessages = messages
|
||||||
if gracefulTerminal {
|
if gracefulTerminal {
|
||||||
|
|
@ -2138,7 +2073,6 @@ turnLoop:
|
||||||
if response.Usage != nil {
|
if response.Usage != nil {
|
||||||
innerTS.SetLastUsage(response.Usage)
|
innerTS.SetLastUsage(response.Usage)
|
||||||
}
|
}
|
||||||
return "", iteration, false, fmt.Errorf("LLM call failed after retries: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go al.handleReasoning(
|
go al.handleReasoning(
|
||||||
|
|
@ -2189,7 +2123,6 @@ turnLoop:
|
||||||
"agent_id": ts.agent.ID,
|
"agent_id": ts.agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"content_chars": len(finalContent),
|
"content_chars": len(finalContent),
|
||||||
"streamed": streamer != nil,
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -2211,6 +2144,7 @@ turnLoop:
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
allResponsesHandled := len(normalizedToolCalls) > 0
|
||||||
assistantMsg := providers.Message{
|
assistantMsg := providers.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: response.Content,
|
Content: response.Content,
|
||||||
|
|
@ -2460,18 +2394,11 @@ turnLoop:
|
||||||
if toolResult == nil {
|
if toolResult == nil {
|
||||||
toolResult = tools.ErrorResult("hook returned nil tool result")
|
toolResult = tools.ErrorResult("hook returned nil tool result")
|
||||||
}
|
}
|
||||||
|
if !toolResult.ResponseHandled {
|
||||||
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 {
|
|
||||||
allResponsesHandled = false
|
allResponsesHandled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send ForUser content to user immediately if not Silent
|
if !toolResult.Silent && toolResult.ForUser != "" && ts.opts.SendResponse {
|
||||||
if !r.result.Silent && r.result.ForUser != "" && opts.SendResponse {
|
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: ts.channel,
|
Channel: ts.channel,
|
||||||
ChatID: ts.chatID,
|
ChatID: ts.chatID,
|
||||||
|
|
@ -2493,24 +2420,7 @@ turnLoop:
|
||||||
part.Filename = meta.Filename
|
part.Filename = meta.Filename
|
||||||
part.ContentType = meta.ContentType
|
part.ContentType = meta.ContentType
|
||||||
part.Type = inferMediaType(meta.Filename, 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)
|
parts = append(parts, part)
|
||||||
}
|
}
|
||||||
|
|
@ -2521,14 +2431,10 @@ turnLoop:
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
contentForLLM := toolResult.ForLLM
|
if len(toolResult.Media) > 0 && !toolResult.ResponseHandled {
|
||||||
if contentForLLM == "" && toolResult.Err != nil {
|
toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
contentForLLM := r.result.ContentForLLM()
|
contentForLLM := toolResult.ContentForLLM()
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
toolResultMsg := providers.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
|
|
@ -2617,31 +2523,48 @@ turnLoop:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ts.agent.Tools.TickTTL()
|
|
||||||
if allResponsesHandled {
|
if allResponsesHandled {
|
||||||
summaryMsg := providers.Message{
|
summaryMsg := providers.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: handledToolResponseSummary,
|
Content: handledToolResponseSummary,
|
||||||
}
|
}
|
||||||
messages = append(messages, summaryMsg)
|
messages = append(messages, summaryMsg)
|
||||||
agent.Sessions.AddFullMessage(opts.SessionKey, summaryMsg)
|
if !ts.opts.NoHistory {
|
||||||
|
ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content)
|
||||||
logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM",
|
ts.recordPersistedMessage(summaryMsg)
|
||||||
map[string]any{
|
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
|
||||||
"agent_id": agent.ID,
|
turnStatus = TurnEndStatusError
|
||||||
"iteration": iteration,
|
al.emitEvent(
|
||||||
"tool_count": len(agentResults),
|
EventKindError,
|
||||||
})
|
ts.eventMeta("runTurn", "turn.error"),
|
||||||
return "", iteration, true, nil
|
ErrorPayload{
|
||||||
|
Stage: "session_save",
|
||||||
|
Message: err.Error(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return turnResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ts.opts.EnableSummary {
|
||||||
|
al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick down TTL of discovered tools after processing tool results.
|
ts.setPhase(TurnPhaseCompleted)
|
||||||
// Only reached when tool calls were made (the loop continues);
|
ts.setFinalContent("")
|
||||||
// the break on no-tool-call responses skips this.
|
logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM",
|
||||||
// NOTE: This is safe because processMessage is sequential per agent.
|
map[string]any{
|
||||||
// If per-agent concurrency is added, TTL consistency between
|
"agent_id": ts.agent.ID,
|
||||||
// ToProviderDefs and Get must be re-evaluated.
|
"iteration": iteration,
|
||||||
agent.Tools.TickTTL()
|
"tool_count": len(normalizedToolCalls),
|
||||||
|
})
|
||||||
|
return turnResult{
|
||||||
|
finalContent: "",
|
||||||
|
status: turnStatus,
|
||||||
|
followUps: append([]bus.InboundMessage(nil), ts.followUps...),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.agent.Tools.TickTTL()
|
||||||
logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{
|
logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{
|
||||||
"agent_id": ts.agent.ID, "iteration": iteration,
|
"agent_id": ts.agent.ID, "iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
@ -2664,7 +2587,6 @@ turnLoop:
|
||||||
return al.abortTurn(ts)
|
return al.abortTurn(ts)
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalContent, iteration, false, nil
|
|
||||||
if finalContent == "" {
|
if finalContent == "" {
|
||||||
if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 {
|
if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 {
|
||||||
finalContent = toolLimitResponse
|
finalContent = toolLimitResponse
|
||||||
|
|
@ -3212,6 +3134,10 @@ func (al *AgentLoop) handleCommand(
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched {
|
||||||
|
return reply, handled
|
||||||
|
}
|
||||||
|
|
||||||
if al.cmdRegistry == nil {
|
if al.cmdRegistry == nil {
|
||||||
return "", false
|
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 {
|
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
|
||||||
registry := al.GetRegistry()
|
registry := al.GetRegistry()
|
||||||
cfg := al.GetConfig()
|
cfg := al.GetConfig()
|
||||||
|
|
@ -3282,6 +3299,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
||||||
return al.reloadFunc()
|
return al.reloadFunc()
|
||||||
}
|
}
|
||||||
if agent != nil {
|
if agent != nil {
|
||||||
|
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
||||||
rt.GetModelInfo = func() (string, string) {
|
rt.GetModelInfo = func() (string, string) {
|
||||||
return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider)
|
return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider)
|
||||||
}
|
}
|
||||||
|
|
@ -3334,6 +3352,74 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
|
||||||
return rt
|
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 {
|
func mapCommandError(result commands.ExecuteResult) string {
|
||||||
if result.Command == "" {
|
if result.Command == "" {
|
||||||
return fmt.Sprintf("Failed to execute command: %v", result.Err)
|
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
|
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.
|
// detectMIME determines the MIME type from metadata or magic-bytes detection.
|
||||||
// Returns empty string if detection fails.
|
// Returns empty string if detection fails.
|
||||||
func detectMIME(localPath string, meta media.MediaMeta) string {
|
func detectMIME(localPath string, meta media.MediaMeta) string {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestRecordLastChannel(t *testing.T) {
|
||||||
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -381,7 +461,7 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
|
||||||
t.Fatal("expected session history to be saved")
|
t.Fatal("expected session history to be saved")
|
||||||
}
|
}
|
||||||
last := history[len(history)-1]
|
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)
|
t.Fatalf("expected handled assistant summary in history, got %+v", last)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition {
|
||||||
helpCommand(),
|
helpCommand(),
|
||||||
showCommand(),
|
showCommand(),
|
||||||
listCommand(),
|
listCommand(),
|
||||||
|
useCommand(),
|
||||||
switchCommand(),
|
switchCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
clearCommand(),
|
clearCommand(),
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,12 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||||
if !strings.Contains(reply, "/show [model|channel|agents]") {
|
if !strings.Contains(reply, "/show [model|channel|agents]") {
|
||||||
t.Fatalf("/help reply missing /show usage, got %q", reply)
|
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)
|
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) {
|
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,23 @@ func listCommand() Definition {
|
||||||
Description: "Registered agents",
|
Description: "Registered agents",
|
||||||
Handler: agentsHandler(),
|
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- "),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
pkg/commands/cmd_use.go
Normal file
9
pkg/commands/cmd_use.go
Normal file
|
|
@ -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
|
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) {
|
func trimCommandPrefix(token string) (string, bool) {
|
||||||
for _, prefix := range commandPrefixes {
|
for _, prefix := range commandPrefixes {
|
||||||
if strings.HasPrefix(token, prefix) {
|
if strings.HasPrefix(token, prefix) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type Runtime struct {
|
||||||
GetModelInfo func() (name, provider string)
|
GetModelInfo func() (name, provider string)
|
||||||
ListAgentIDs func() []string
|
ListAgentIDs func() []string
|
||||||
ListDefinitions func() []Definition
|
ListDefinitions func() []Definition
|
||||||
|
ListSkillNames func() []string
|
||||||
GetEnabledChannels func() []string
|
GetEnabledChannels func() []string
|
||||||
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
||||||
SwitchModel func(value string) (oldModel string, err error)
|
SwitchModel func(value string) (oldModel string, err error)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue