From cb82322df21df786f052d97a69b8d3436fd84061 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:44:49 +0100 Subject: [PATCH] feat: add inner monologue template support for scheduler and template-based skills prompt (#435) * feat: add scheduler task template and template-based skills prompt - Add WithSchedulerTaskTemplate option for recurring tasks run by scheduler - Expose scheduler_task_template in core/state/config.go for user configuration - Add WithSkillPromptTemplate option for custom skill prompt templates - Use {{.Skills}} slice in templates to iterate over Skill.Name, Skill.Description - Default template mimics current XML behavior with format - Rename customIntro to customTemplate in services/skills for consistency * cleanups Signed-off-by: Ettore Di Giacinto * Update core/state/config.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- core/agent/options.go | 21 +++++++- core/agent/scheduler_executor.go | 24 ++++++++- core/state/config.go | 9 ++++ core/state/pool.go | 1 + services/skills/prompt.go | 87 +++++++++++++++++++++++--------- services/skills/service.go | 6 +-- 6 files changed, 119 insertions(+), 29 deletions(-) diff --git a/core/agent/options.go b/core/agent/options.go index 31cfd2d..f2f09fe 100644 --- a/core/agent/options.go +++ b/core/agent/options.go @@ -71,8 +71,10 @@ type options struct { prompts []DynamicPrompt - systemPrompt string + systemPrompt string innerMonologueTemplate string + skillPromptTemplate string + schedulerTaskTemplate string // callbacks reasoningCallback func(types.ActionCurrentState) bool @@ -319,6 +321,14 @@ func WithInnerMonologueTemplate(template string) Option { } } +// WithSkillPromptTemplate sets the template for rendering skills in the prompt. If empty, the default template is used. +func WithSkillPromptTemplate(template string) Option { + return func(o *options) error { + o.skillPromptTemplate = template + return nil + } +} + func WithMCPServers(servers ...MCPServer) Option { return func(o *options) error { o.mcpServers = servers @@ -569,3 +579,12 @@ func WithSchedulerStorePath(path string) Option { return nil } } + +// WithSchedulerTaskTemplate sets the prompt used for scheduled/recurring tasks run by the scheduler. +// If empty, the default inner monologue template is used with the task injected. +func WithSchedulerTaskTemplate(template string) Option { + return func(o *options) error { + o.schedulerTaskTemplate = template + return nil + } +} diff --git a/core/agent/scheduler_executor.go b/core/agent/scheduler_executor.go index 16acff9..8659e97 100644 --- a/core/agent/scheduler_executor.go +++ b/core/agent/scheduler_executor.go @@ -15,9 +15,29 @@ type agentSchedulerExecutor struct { // Execute processes a scheduled task by creating a job for the agent func (e *agentSchedulerExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) { - // Create a job for the reminder + // Render the scheduler task template - if custom template is set, it will include {{.Task}} + // If no custom scheduler template is set, fall back to default inner monologue template + innerMonologue := fmt.Sprintf("You need to execute the following task, by using the tools available to you. When the task is completed, you need to send a message to the user with send_message tool to inform them that the task is completed: %s", prompt) + + if e.agent.options.schedulerTaskTemplate != "" { + tmpl, err := templateBase("taskTemplate", e.agent.options.schedulerTaskTemplate) + if err != nil { + return nil, fmt.Errorf("failed to render scheduler task template: %w", err) + } + + innerMonologue, err = templateExecute(tmpl, struct { + Task string + }{ + Task: prompt, + }) + if err != nil { + return nil, fmt.Errorf("failed to render scheduler task template: %w", err) + } + } + + // Create a job for the reminder with the rendered inner monologue reminderJob := types.NewJob( - types.WithText(fmt.Sprintf("You need to execute the following task, by using the tools available to you. When the task is completed, you need to send a message to the user with send_message tool to inform them that the task is completed: %s", prompt)), + types.WithText(innerMonologue), types.WithReasoningCallback(e.agent.options.reasoningCallback), types.WithResultCallback(e.agent.options.resultCallback), types.WithContext(ctx), diff --git a/core/state/config.go b/core/state/config.go index 8420f87..a5f3d07 100644 --- a/core/state/config.go +++ b/core/state/config.go @@ -85,6 +85,7 @@ type AgentConfig struct { IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"` PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"` SchedulerPollInterval string `json:"scheduler_poll_interval" form:"scheduler_poll_interval"` + SchedulerTaskTemplate string `json:"scheduler_task_template" form:"scheduler_task_template"` PermanentGoal string `json:"permanent_goal" form:"permanent_goal"` EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"` EnableKBCompaction bool `json:"enable_kb_compaction" form:"enable_kb_compaction"` @@ -352,6 +353,14 @@ func NewAgentConfigMeta( HelpText: "Prompt used for periodic/standalone runs when the agent evaluates what to do next. If empty, the default autonomous agent instructions are used.", Tags: config.Tags{Section: "PromptsGoals"}, }, + { + Name: "scheduler_task_template", + Label: "Scheduler Task Template", + Type: "textarea", + DefaultValue: "", + HelpText: "Template for scheduled/recurring tasks. Use {{.Task}} to reference the task. Example: \"Execute: {{.Task}}\". If empty, the default inner monologue template is used with the task injected.", + Tags: config.Tags{Section: "PromptsGoals"}, + }, { Name: "standalone_job", Label: "Standalone Job", diff --git a/core/state/pool.go b/core/state/pool.go index 49c2e1f..0464380 100644 --- a/core/state/pool.go +++ b/core/state/pool.go @@ -441,6 +441,7 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf }), WithSystemPrompt(config.SystemPrompt), WithInnerMonologueTemplate(config.InnerMonologueTemplate), + WithSchedulerTaskTemplate(config.SchedulerTaskTemplate), WithMultimodalModel(multimodalModel), WithLastMessageDuration(config.LastMessageDuration), WithAgentResultCallback(func(state types.ActionState) { diff --git a/services/skills/prompt.go b/services/skills/prompt.go index fb74cf0..298d686 100644 --- a/services/skills/prompt.go +++ b/services/skills/prompt.go @@ -1,9 +1,12 @@ package skills import ( + "bytes" "fmt" "strings" + "text/template" + "github.com/Masterminds/sprig/v3" "github.com/mudler/LocalAGI/core/agent" "github.com/mudler/LocalAGI/core/types" @@ -12,16 +15,34 @@ import ( const defaultSkillsIntro = "You can use the following skills to help with the task.\nTo request the skill, you need to use the `request_skill` tool. The skill name is the name of the skill you want to use.\n" -// skillsPrompt implements agent.DynamicPrompt and injects the available skills XML block -type skillsPrompt struct { - listSkills func() ([]skilldomain.Skill, error) - customIntro string +// defaultSkillsTemplate is the default template that mimics the current XML behavior +const defaultSkillsTemplate = defaultSkillsIntro + ` +{{range .Skills}} + + {{escapeXML .Name}} + {{escapeXML .Description}} + +{{end}} +` + +// Skill is a local representation of a skill for template rendering +type Skill struct { + Name string + Description string + ID string } -// NewSkillsPrompt returns a DynamicPrompt that renders the list of available skills as XML. -// If customIntro is non-empty, it is used as the intro before the skills list; otherwise the default intro is used. -func NewSkillsPrompt(listSkills func() ([]skilldomain.Skill, error), customIntro string) agent.DynamicPrompt { - return &skillsPrompt{listSkills: listSkills, customIntro: customIntro} +// skillsPrompt implements agent.DynamicPrompt and injects the available skills XML block +type skillsPrompt struct { + listSkills func() ([]skilldomain.Skill, error) + customTemplate string +} + +// NewSkillsPrompt returns a DynamicPrompt that renders the list of available skills. +// If customTemplate is non-empty, it is used as a template with {{.Skills}} slice. +// Otherwise, the default template is used (mimics current XML behavior). +func NewSkillsPrompt(listSkills func() ([]skilldomain.Skill, error), customTemplate string) agent.DynamicPrompt { + return &skillsPrompt{listSkills: listSkills, customTemplate: customTemplate} } func (p *skillsPrompt) Render(a *agent.Agent) (types.PromptResult, error) { @@ -29,26 +50,46 @@ func (p *skillsPrompt) Render(a *agent.Agent) (types.PromptResult, error) { if err != nil { return types.PromptResult{}, err } - var sb strings.Builder - intro := defaultSkillsIntro - if p.customIntro != "" { - intro = strings.TrimSpace(p.customIntro) + "\n" - } - sb.WriteString(intro) - sb.WriteString("\n") - for _, s := range skills { - name := s.ID + + // Convert skilldomain.Skill to local Skill type for template rendering + localSkills := make([]Skill, len(skills)) + for i, s := range skills { desc := "" if s.Metadata != nil && s.Metadata.Description != "" { desc = s.Metadata.Description } - sb.WriteString(" \n") - sb.WriteString(fmt.Sprintf(" %s\n", escapeXML(name))) - sb.WriteString(fmt.Sprintf(" %s\n", escapeXML(desc))) - sb.WriteString(" \n") + localSkills[i] = Skill{ + Name: s.ID, + Description: desc, + ID: s.ID, + } } - sb.WriteString("") - return types.PromptResult{Content: sb.String()}, nil + + // Use custom template or default + templ := p.customTemplate + if templ == "" { + templ = defaultSkillsTemplate + } + + // Parse and execute the template + tmpl, err := template.New("skillsPrompt").Funcs(template.FuncMap{ + "escapeXML": escapeXML, + }).Funcs(sprig.FuncMap()).Parse(templ) + if err != nil { + return types.PromptResult{}, fmt.Errorf("failed to parse skills template: %w", err) + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, struct { + Skills []Skill + }{ + Skills: localSkills, + }) + if err != nil { + return types.PromptResult{}, fmt.Errorf("failed to execute skills template: %w", err) + } + + return types.PromptResult{Content: buf.String()}, nil } func (p *skillsPrompt) Role() string { diff --git a/services/skills/service.go b/services/skills/service.go index 8b2b082..66312db 100644 --- a/services/skills/service.go +++ b/services/skills/service.go @@ -135,11 +135,11 @@ func (s *Service) GetSkillsPrompt(config *state.AgentConfig) (agent.DynamicPromp if err != nil || mgr == nil { return nil, err } - customIntro := "" + customTemplate := "" if config != nil && config.SkillsPrompt != "" { - customIntro = config.SkillsPrompt + customTemplate = config.SkillsPrompt } - return NewSkillsPrompt(mgr.ListSkills, customIntro), nil + return NewSkillsPrompt(mgr.ListSkills, customTemplate), nil } // GetMCPSession returns a shared MCP client session connected to the in-process skillserver (starts on first use)