resolve conflicts

This commit is contained in:
afjcjsbx
2026-03-22 23:36:25 +01:00
parent 14a4983af3
commit b90c5007f6
10 changed files with 418 additions and 130 deletions
+68
View File
@@ -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
View File
@@ -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)
+18
View File
@@ -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
View File
@@ -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)
}
}
+1
View File
@@ -10,6 +10,7 @@ func BuiltinDefinitions() []Definition {
helpCommand(),
showCommand(),
listCommand(),
useCommand(),
switchCommand(),
checkCommand(),
clearCommand(),
+4 -1
View File
@@ -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) {
+17
View File
@@ -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- "),
))
},
},
},
}
}
+9
View 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]",
}
}
+5
View File
@@ -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) {
+1
View File
@@ -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)