refactor: replace skill code with skill ID across the application

- Updated skill handling to use skill IDs instead of skill codes in various components, services, and models.
- Modified tests to reflect changes in skill identification.
- Removed references to skill codes in favor of skill IDs for consistency and clarity.
- Updated localization files to remove skill code references and adjust error messages accordingly.
This commit is contained in:
mlogclub
2026-06-20 20:42:07 +08:00
parent 39c648f3c1
commit 541e4c5874
49 changed files with 258 additions and 341 deletions
+14 -7
View File
@@ -2,6 +2,7 @@ package runtime
import (
"context"
"fmt"
"strings"
applicationruntime "agent-desk/internal/ai/application/runtime"
@@ -28,6 +29,12 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp
if aiConfig == nil {
return nil, errorsx.InvalidParamI18n("error.e0008")
}
skill := svc.SkillDefinitionService.Get(req.SkillDefinitionID)
if skill == nil || skill.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0054")
}
debugAgent := *aiAgent
debugAgent.SkillIDs = fmt.Sprintf("%d", skill.ID)
var conversation *models.Conversation
if req.ConversationID > 0 {
if conversation = svc.ConversationService.Get(req.ConversationID); conversation == nil {
@@ -45,13 +52,13 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp
summary, err := Service.Run(ctx, applicationruntime.Request{
Conversation: *conversation,
UserMessage: message,
AIAgent: *aiAgent,
AIAgent: debugAgent,
AIConfig: *aiConfig,
})
if err != nil {
return buildSkillDebugRunResponse(req, summary, nil), err
return buildSkillDebugRunResponse(req, summary, skill), err
}
return buildSkillDebugRunResponse(req, summary, nil), nil
return buildSkillDebugRunResponse(req, summary, skill), nil
}
func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest) (*response.SkillDebugRunResponse, error) {
@@ -125,14 +132,14 @@ func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *appli
AIAgentID: req.AIAgentID,
}
if skill != nil {
resp.SkillCode = skill.Code
resp.SkillDefinitionID = skill.ID
resp.SkillName = skill.Name
}
if summary == nil {
return resp
}
if resp.SkillCode == "" {
resp.SkillCode = strings.TrimSpace(summary.PlannedSkillCode)
if resp.SkillDefinitionID <= 0 {
resp.SkillDefinitionID = summary.PlannedSkillID
}
resp.ReplyText = summary.ReplyText
resp.PlanReason = summary.PlanReason
@@ -159,7 +166,7 @@ func buildSkillDebugResumeResponse(req request.SkillDebugResumeRequest, summary
if summary == nil {
return resp
}
resp.SkillCode = strings.TrimSpace(summary.PlannedSkillCode)
resp.SkillDefinitionID = summary.PlannedSkillID
resp.SkillName = strings.TrimSpace(summary.PlannedSkillName)
resp.ReplyText = summary.ReplyText
resp.PlanReason = summary.PlanReason
+1 -1
View File
@@ -213,7 +213,7 @@ func syncSkillSummaryFromCollector(summary *RunResult, collector *callbacks.Runt
return
}
trace := collector.Data.Skill
summary.SelectedSkillCode = strings.TrimSpace(trace.Code)
summary.SelectedSkillID = trace.ID
summary.SelectedSkillName = strings.TrimSpace(trace.Name)
summary.SkillRouteReason = strings.TrimSpace(trace.RouteReason)
summary.SkillRouteTrace = strings.TrimSpace(trace.RouteTrace)
+1 -1
View File
@@ -33,7 +33,7 @@ type RunResult struct {
RunID string
Status string
ReplyText string
SelectedSkillCode string
SelectedSkillID int64
SelectedSkillName string
SkillRouteReason string
SkillRouteTrace string
+2 -2
View File
@@ -16,7 +16,7 @@ func BuildSelectedSkillActivationInstruction(skill *models.SkillDefinition) stri
}
lines := []string{
"当前命中的专项技能:",
fmt.Sprintf("- code: %s", strings.TrimSpace(skill.Code)),
fmt.Sprintf("- id: %d", skill.ID),
fmt.Sprintf("- name: %s", strings.TrimSpace(skill.Name)),
}
if desc := strings.TrimSpace(skill.Description); desc != "" {
@@ -36,7 +36,7 @@ func BuildSkillDocument(skill *models.SkillDefinition, toolDefinitions []runtime
}
lines := []string{
"当前命中的专项技能:",
fmt.Sprintf("- code: %s", strings.TrimSpace(skill.Code)),
fmt.Sprintf("- id: %d", skill.ID),
fmt.Sprintf("- name: %s", strings.TrimSpace(skill.Name)),
}
if desc := strings.TrimSpace(skill.Description); desc != "" {
@@ -3,6 +3,7 @@ package callbacks
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
@@ -142,17 +143,19 @@ func (h *RuntimeTraceHandler) tryActivateSkill(argumentsInJSON string) {
if err := json.Unmarshal([]byte(strings.TrimSpace(argumentsInJSON)), &args); err != nil {
return
}
code := strings.TrimSpace(args.Skill)
if code == "" {
skillKey := strings.TrimSpace(args.Skill)
if skillKey == "" {
return
}
meta, ok := h.skillMetadataBy[code]
meta, ok := h.skillMetadataBy[skillKey]
if !ok {
meta = SkillMetadata{Code: code}
if id, err := strconv.ParseInt(skillKey, 10, 64); err == nil {
meta = SkillMetadata{ID: id}
}
}
buf, err := json.Marshal(map[string]any{
"source": "eino_skill_tool",
"skill": code,
"source": "eino_skill_tool",
"skillId": skillKey,
})
routeTrace := ""
if err == nil {
@@ -42,18 +42,18 @@ func TestTryActivateSkill(t *testing.T) {
handler := &RuntimeTraceHandler{
collector: collector,
skillMetadataBy: map[string]SkillMetadata{
"after_sales_escalation_skill": {
Code: "after_sales_escalation_skill",
"44": {
ID: 44,
Name: "售后升级",
AllowedToolCodes: []string{"graph/handoff_to_human"},
},
},
}
handler.tryActivateSkill(`{"skill":"after_sales_escalation_skill"}`)
handler.tryActivateSkill(`{"skill":"44"}`)
if collector.Data.Skill.Code != "after_sales_escalation_skill" {
t.Fatalf("unexpected skill code: %#v", collector.Data.Skill)
if collector.Data.Skill.ID != 44 {
t.Fatalf("unexpected skill id: %#v", collector.Data.Skill)
}
if collector.Data.Skill.Name != "售后升级" {
t.Fatalf("unexpected skill name: %#v", collector.Data.Skill)
@@ -52,7 +52,7 @@ func (c *RuntimeTraceCollector) SetSkillMiddleware(enabled bool, toolName string
}
type SkillMetadata struct {
Code string
ID int64
Name string
Description string
AllowedToolCodes []string
@@ -64,20 +64,20 @@ func (c *RuntimeTraceCollector) SetVisibleSkills(skills map[string]SkillMetadata
}
c.mu.Lock()
defer c.mu.Unlock()
codes := make([]string, 0, len(skills))
for code := range skills {
if code == "" {
ids := make([]int64, 0, len(skills))
for _, skill := range skills {
if skill.ID <= 0 {
continue
}
codes = append(codes, code)
ids = append(ids, skill.ID)
}
c.Data.Skill.VisibleCodes = append([]string(nil), codes...)
c.Data.Skill.VisibleIDs = append([]int64(nil), ids...)
}
func (c *RuntimeTraceCollector) ActivateSkill(skill SkillMetadata, routeReason string, routeTrace string) {
c.mu.Lock()
defer c.mu.Unlock()
c.Data.Skill.Code = skill.Code
c.Data.Skill.ID = skill.ID
c.Data.Skill.Name = skill.Name
c.Data.Skill.Description = skill.Description
c.Data.Skill.AllowedToolCodes = append([]string(nil), skill.AllowedToolCodes...)
@@ -152,7 +152,7 @@ type RuntimeTraceData struct {
}
type SkillTraceData struct {
Code string `json:"code,omitempty"`
ID int64 `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
RouteReason string `json:"routeReason,omitempty"`
@@ -161,7 +161,7 @@ type SkillTraceData struct {
FilteredToolCodes []string `json:"filteredToolCodes,omitempty"`
MiddlewareEnabled bool `json:"middlewareEnabled,omitempty"`
MiddlewareToolName string `json:"middlewareToolName,omitempty"`
VisibleCodes []string `json:"visibleCodes,omitempty"`
VisibleIDs []int64 `json:"visibleIds,omitempty"`
}
type InterruptTraceContext struct {
@@ -38,19 +38,19 @@ func NewAgentHandlerService(skillMiddleware *SkillMiddlewareService) *AgentHandl
func (s *AgentHandlerService) Build(ctx context.Context, input BuildAgentHandlersInput) ([]adk.ChatModelAgentMiddleware, error) {
handlers := make([]adk.ChatModelAgentMiddleware, 0, 4)
skillMetadataByCode := buildRuntimeSkillMetadataMap(input.AIAgent)
toolMetadataBy := buildRuntimeTraceToolMetadata(input.DynamicToolDefinitions, input.StaticToolMetadata, len(skillMetadataByCode) > 0)
traceSkillMetadata := make(map[string]einocallbacks.SkillMetadata, len(skillMetadataByCode))
for code, item := range skillMetadataByCode {
traceSkillMetadata[code] = einocallbacks.SkillMetadata{
Code: item.Code,
skillMetadataByID := buildRuntimeSkillMetadataMap(input.AIAgent)
toolMetadataBy := buildRuntimeTraceToolMetadata(input.DynamicToolDefinitions, input.StaticToolMetadata, len(skillMetadataByID) > 0)
traceSkillMetadata := make(map[string]einocallbacks.SkillMetadata, len(skillMetadataByID))
for id, item := range skillMetadataByID {
traceSkillMetadata[id] = einocallbacks.SkillMetadata{
ID: item.ID,
Name: item.Name,
Description: item.Description,
AllowedToolCodes: append([]string(nil), item.AllowedToolCodes...),
}
}
if input.Collector != nil {
if len(skillMetadataByCode) > 0 {
if len(skillMetadataByID) > 0 {
input.Collector.SetSkillMiddleware(true, toolx.BuiltinSkill.Name)
}
input.Collector.SetVisibleSkills(traceSkillMetadata)
@@ -66,7 +66,7 @@ func (s *AgentHandlerService) Build(ctx context.Context, input BuildAgentHandler
}
handlers = append(handlers, toolSearchHandler)
}
if len(skillMetadataByCode) > 0 {
if len(skillMetadataByID) > 0 {
skillHandler, err := s.skillMiddleware.Build(ctx, input.AIAgent, input.InstructionToolDefinitions)
if err != nil {
return nil, err
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
runtimeinstruction "agent-desk/internal/ai/runtime/instruction"
@@ -17,7 +18,7 @@ import (
)
type runtimeSkillMetadata struct {
Code string
ID int64
Name string
Description string
AllowedToolCodes []string
@@ -25,7 +26,7 @@ type runtimeSkillMetadata struct {
type databaseSkillBackend struct {
toolDefinitions []runtimetooling.MCPToolDefinition
skillsByCode map[string]models.SkillDefinition
skillsByID map[string]models.SkillDefinition
order []string
}
@@ -36,18 +37,18 @@ func newDatabaseSkillBackend(aiAgent models.AIAgent, toolDefinitions []runtimeto
}
ret := &databaseSkillBackend{
toolDefinitions: append([]runtimetooling.MCPToolDefinition(nil), toolDefinitions...),
skillsByCode: make(map[string]models.SkillDefinition, len(visibleSkills)),
skillsByID: make(map[string]models.SkillDefinition, len(visibleSkills)),
order: make([]string, 0, len(visibleSkills)),
}
for _, item := range visibleSkills {
code := strings.TrimSpace(item.Code)
if code == "" {
id := strconv.FormatInt(item.ID, 10)
if id == "" {
continue
}
ret.skillsByCode[code] = item
ret.order = append(ret.order, code)
ret.skillsByID[id] = item
ret.order = append(ret.order, id)
}
if len(ret.skillsByCode) == 0 {
if len(ret.skillsByID) == 0 {
return nil, fmt.Errorf("no visible skills available")
}
return ret, nil
@@ -58,13 +59,13 @@ func (b *databaseSkillBackend) List(_ context.Context) ([]einoskill.FrontMatter,
return nil, nil
}
ret := make([]einoskill.FrontMatter, 0, len(b.order))
for _, code := range b.order {
item, ok := b.skillsByCode[code]
for _, id := range b.order {
item, ok := b.skillsByID[id]
if !ok {
continue
}
ret = append(ret, einoskill.FrontMatter{
Name: strings.TrimSpace(item.Code),
Name: strconv.FormatInt(item.ID, 10),
Description: skillListDescription(item),
})
}
@@ -79,13 +80,13 @@ func (b *databaseSkillBackend) Get(_ context.Context, name string) (einoskill.Sk
if name == "" {
return einoskill.Skill{}, fmt.Errorf("skill name is empty")
}
item, ok := b.skillsByCode[name]
item, ok := b.skillsByID[name]
if !ok {
return einoskill.Skill{}, fmt.Errorf("skill %q not found", name)
}
return einoskill.Skill{
FrontMatter: einoskill.FrontMatter{
Name: strings.TrimSpace(item.Code),
Name: strconv.FormatInt(item.ID, 10),
Description: skillListDescription(item),
},
Content: runtimeinstruction.BuildSkillDocument(&item, filterSkillToolDefinitions(b.toolDefinitions, &item)),
@@ -105,7 +106,7 @@ func loadVisibleSkills(aiAgent models.AIAgent) []models.SkillDefinition {
ret := make([]models.SkillDefinition, 0, len(ids))
for _, id := range ids {
item, ok := byID[id]
if !ok || item.Status != enums.StatusOk || strings.TrimSpace(item.Code) == "" {
if !ok || item.Status != enums.StatusOk || item.ID <= 0 {
continue
}
ret = append(ret, item)
@@ -120,12 +121,12 @@ func buildRuntimeSkillMetadataMap(aiAgent models.AIAgent) map[string]runtimeSkil
}
ret := make(map[string]runtimeSkillMetadata, len(visibleSkills))
for _, item := range visibleSkills {
code := strings.TrimSpace(item.Code)
if code == "" {
if item.ID <= 0 {
continue
}
ret[code] = runtimeSkillMetadata{
Code: code,
id := strconv.FormatInt(item.ID, 10)
ret[id] = runtimeSkillMetadata{
ID: item.ID,
Name: strings.TrimSpace(item.Name),
Description: skillListDescription(item),
AllowedToolCodes: parseSkillToolWhitelist(item.ToolWhitelist),
@@ -145,7 +146,7 @@ func skillListDescription(item models.SkillDefinition) string {
if name := strings.TrimSpace(item.Name); name != "" {
return name
}
return strings.TrimSpace(item.Code)
return fmt.Sprintf("Skill %d", item.ID)
}
func parseSkillToolWhitelist(raw string) []string {
@@ -19,7 +19,6 @@ func TestDatabaseSkillBackendListAndGet(t *testing.T) {
setupSkillBackendTestDB(t)
createSkillDefinitionForTest(t, models.SkillDefinition{
ID: 1,
Code: "after_sales_escalation_skill",
Name: "售后升级",
Description: "处理转人工和升级诉求",
Instruction: "请优先判断是否需要转人工。",
@@ -28,7 +27,6 @@ func TestDatabaseSkillBackendListAndGet(t *testing.T) {
})
createSkillDefinitionForTest(t, models.SkillDefinition{
ID: 2,
Code: "disabled_skill",
Name: "禁用技能",
Description: "不会被暴露",
Instruction: "noop",
@@ -47,15 +45,15 @@ func TestDatabaseSkillBackendListAndGet(t *testing.T) {
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(matters) != 1 || matters[0].Name != "after_sales_escalation_skill" {
if len(matters) != 1 || matters[0].Name != "1" {
t.Fatalf("unexpected matters: %#v", matters)
}
skill, err := backend.Get(context.Background(), "after_sales_escalation_skill")
skill, err := backend.Get(context.Background(), "1")
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if skill.Name != "after_sales_escalation_skill" {
if skill.Name != "1" {
t.Fatalf("unexpected skill name: %#v", skill)
}
if skill.Content == "" || !containsAll(skill.Content, "处理转人工和升级诉求", "graph/handoff_to_human") {
@@ -67,7 +65,6 @@ func TestHasVisibleSkills(t *testing.T) {
setupSkillBackendTestDB(t)
createSkillDefinitionForTest(t, models.SkillDefinition{
ID: 3,
Code: "enabled_skill",
Name: "启用技能",
Description: "可见",
Instruction: "noop",
@@ -75,7 +72,6 @@ func TestHasVisibleSkills(t *testing.T) {
})
createSkillDefinitionForTest(t, models.SkillDefinition{
ID: 4,
Code: "deleted_skill",
Name: "删除技能",
Description: "不可见",
Instruction: "noop",
@@ -15,7 +15,7 @@ import (
"github.com/cloudwego/eino/schema"
)
const activeSkillRunLocalKey = "runtime_active_skill_code"
const activeSkillRunLocalKey = "runtime_active_skill_id"
type RuntimeToolFilterMiddleware struct {
*adk.BaseChatModelAgentMiddleware
@@ -72,7 +72,7 @@ func (m *RuntimeToolFilterMiddleware) WrapInvokableToolCall(_ context.Context, e
return result, err
}
if strings.TrimSpace(metadata.ToolCode) == toolx.BuiltinSkill.Code {
_ = m.setActiveSkill(ctx, skillCodeFromArguments(argumentsInJSON))
_ = m.setActiveSkill(ctx, skillIDFromArguments(argumentsInJSON))
return result, nil
}
if strings.TrimSpace(metadata.ToolCode) == toolx.BuiltinToolSearch.Code {
@@ -90,7 +90,7 @@ func (m *RuntimeToolFilterMiddleware) WrapInvokableToolCall(_ context.Context, e
}
func (m *RuntimeToolFilterMiddleware) blockToolCall(metadata einocallbacks.ToolMetadata, argumentsInJSON string, activeSkill einocallbacks.SkillMetadata) error {
err := fmt.Errorf("tool %s is not allowed for active skill %s", strings.TrimSpace(metadata.ToolCode), strings.TrimSpace(activeSkill.Code))
err := fmt.Errorf("tool %s is not allowed for active skill %d", strings.TrimSpace(metadata.ToolCode), activeSkill.ID)
if m.collector != nil {
m.collector.AddToolItem(einocallbacks.ToolTraceItem{
ToolCode: strings.TrimSpace(metadata.ToolCode),
@@ -106,12 +106,12 @@ func (m *RuntimeToolFilterMiddleware) blockToolCall(metadata einocallbacks.ToolM
return err
}
func (m *RuntimeToolFilterMiddleware) setActiveSkill(ctx context.Context, skillCode string) error {
skillCode = strings.TrimSpace(skillCode)
if skillCode == "" {
func (m *RuntimeToolFilterMiddleware) setActiveSkill(ctx context.Context, skillID string) error {
skillID = strings.TrimSpace(skillID)
if skillID == "" {
return nil
}
return adk.SetRunLocalValue(ctx, activeSkillRunLocalKey, skillCode)
return adk.SetRunLocalValue(ctx, activeSkillRunLocalKey, skillID)
}
func (m *RuntimeToolFilterMiddleware) resolveActiveSkill(ctx context.Context) (einocallbacks.SkillMetadata, bool) {
@@ -122,15 +122,15 @@ func (m *RuntimeToolFilterMiddleware) resolveActiveSkill(ctx context.Context) (e
if err != nil || !found {
return einocallbacks.SkillMetadata{}, false
}
code, ok := value.(string)
skillID, ok := value.(string)
if !ok {
return einocallbacks.SkillMetadata{}, false
}
code = strings.TrimSpace(code)
if code == "" {
skillID = strings.TrimSpace(skillID)
if skillID == "" {
return einocallbacks.SkillMetadata{}, false
}
skill, ok := m.skillMetadataBy[code]
skill, ok := m.skillMetadataBy[skillID]
if !ok {
return einocallbacks.SkillMetadata{}, false
}
@@ -179,12 +179,12 @@ func resolveActiveSkillMetadata(ctx context.Context, skills map[string]einocallb
if err != nil || !found {
return einocallbacks.SkillMetadata{}, false
}
code, ok := value.(string)
skillID, ok := value.(string)
if !ok {
return einocallbacks.SkillMetadata{}, false
}
code = strings.TrimSpace(code)
skill, ok := skills[code]
skillID = strings.TrimSpace(skillID)
skill, ok := skills[skillID]
if !ok || len(skill.AllowedToolCodes) == 0 {
return skill, false
}
@@ -330,7 +330,7 @@ func resolveRuntimeToolMetadata(toolName string, toolMetadataByName map[string]e
return metadata, ok
}
func skillCodeFromArguments(argumentsInJSON string) string {
func skillIDFromArguments(argumentsInJSON string) string {
var args struct {
Skill string `json:"skill"`
}
+1 -1
View File
@@ -26,7 +26,7 @@ func TestSummaryPrimaryToolCodePrefersToolSearchTarget(t *testing.T) {
}
func TestToRunLogFinalAction(t *testing.T) {
if got := toRunLogFinalAction(&applicationruntime.Summary{PlannedSkillCode: "refund", ReplyText: "ok"}); got != "skill" {
if got := toRunLogFinalAction(&applicationruntime.Summary{PlannedSkillID: 44, ReplyText: "ok"}); got != "skill" {
t.Fatalf("expected skill final action, got %q", got)
}
+6 -6
View File
@@ -46,7 +46,7 @@ func (s *replyRunLogService) Write(input replyRunLogInput) {
AIConfigID: input.AIAgent.AIConfigID,
UserMessage: strings.TrimSpace(input.Question),
PlannedAction: plannedAction,
PlannedSkillCode: strings.TrimSpace(summaryPlannedSkillCode(input.Summary)),
PlannedSkillID: summaryPlannedSkillID(input.Summary),
PlannedSkillName: strings.TrimSpace(summaryPlannedSkillName(input.Summary)),
SkillRouteTrace: strings.TrimSpace(summarySkillRouteTrace(input.Summary)),
ToolSearchTrace: extractToolSearchTrace(input.Summary),
@@ -90,7 +90,7 @@ func buildRunLogPlan(summary *applicationruntime.Summary) (plannedAction, planne
if summary == nil {
return "", "", ""
}
if skillCode := strings.TrimSpace(summaryPlannedSkillCode(summary)); skillCode != "" {
if summaryPlannedSkillID(summary) > 0 {
reason := strings.TrimSpace(summary.PlanReason)
if reason == "" {
reason = "skill_selected"
@@ -138,7 +138,7 @@ func toRunLogFinalAction(summary *applicationruntime.Summary) string {
if summary == nil {
return ""
}
if skillCode := strings.TrimSpace(summaryPlannedSkillCode(summary)); skillCode != "" && strings.TrimSpace(summary.ReplyText) != "" {
if summaryPlannedSkillID(summary) > 0 && strings.TrimSpace(summary.ReplyText) != "" {
return "skill"
}
if graphToolCode := firstGraphToolCode(summary); graphToolCode != "" && strings.TrimSpace(summary.ReplyText) != "" {
@@ -167,11 +167,11 @@ func buildRunLogReplyText(summary *applicationruntime.Summary) string {
return strings.TrimSpace(summary.ReplyText)
}
func summaryPlannedSkillCode(summary *applicationruntime.Summary) string {
func summaryPlannedSkillID(summary *applicationruntime.Summary) int64 {
if summary == nil {
return ""
return 0
}
return strings.TrimSpace(summary.PlannedSkillCode)
return summary.PlannedSkillID
}
func summaryPlannedSkillName(summary *applicationruntime.Summary) string {
+2 -2
View File
@@ -72,8 +72,8 @@ func TestResolveReplyTimeout(t *testing.T) {
func TestBuildRunLogPlan(t *testing.T) {
summary := &applicationruntime.Summary{
PlannedSkillCode: "faq_router",
PlanReason: "manual",
PlannedSkillID: 44,
PlanReason: "manual",
}
action, toolCode, reason := buildRunLogPlan(summary)
if action != "skill" || toolCode != "" || reason != "manual" {