feat: add context/prompt API and bootstrap file resolution to Mini App
- ContextBuilder: workDir field, SetWorkDir(), ResolveBootstrapPaths() with project-scoped search (workDir → planWorkDir → workspace) - Mini App: /api/context and /api/prompt endpoints, SSE context events - DataProvider: GetContextInfo() and GetSystemPrompt() interface methods - loop.go: SetWorkDir per session, GetContextInfo() delegation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f92dcfd908
commit
b516f41a35
6 changed files with 231 additions and 18 deletions
|
|
@ -396,6 +396,24 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
|
|||
return result
|
||||
}
|
||||
|
||||
func (p *agentLoopDataProvider) GetContextInfo() miniapp.ContextInfo {
|
||||
workDir, planWorkDir, workspace, bootstrap := p.loop.GetContextInfo()
|
||||
files := make([]miniapp.BootstrapFileInfo, len(bootstrap))
|
||||
for i, b := range bootstrap {
|
||||
files[i] = miniapp.BootstrapFileInfo{Name: b.Name, Path: b.Path, Scope: b.Scope}
|
||||
}
|
||||
return miniapp.ContextInfo{
|
||||
WorkDir: workDir,
|
||||
PlanWorkDir: planWorkDir,
|
||||
Workspace: workspace,
|
||||
Bootstrap: files,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *agentLoopDataProvider) GetSystemPrompt() string {
|
||||
return p.loop.GetSystemPrompt()
|
||||
}
|
||||
|
||||
func (p *agentLoopDataProvider) GetGitRepos() []miniapp.GitRepoSummary {
|
||||
if time.Since(p.gitReposCacheAt) < gitCacheTTL {
|
||||
return p.gitReposCache
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
|
||||
type ContextBuilder struct {
|
||||
workspace string
|
||||
workDir string // session-specific working directory (worktree or project subdir)
|
||||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
||||
|
|
@ -49,6 +50,12 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
|||
cb.tools = registry
|
||||
}
|
||||
|
||||
// SetWorkDir sets the session-specific working directory (e.g., worktree path
|
||||
// or project subdirectory). Bootstrap files found here take priority over workspace.
|
||||
func (cb *ContextBuilder) SetWorkDir(dir string) {
|
||||
cb.workDir = dir
|
||||
}
|
||||
|
||||
// SetPeerNote sets the peer session awareness note for the current call.
|
||||
func (cb *ContextBuilder) SetPeerNote(note string) {
|
||||
cb.peerNote = note
|
||||
|
|
@ -190,25 +197,88 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
|||
return strings.Join(parts, "\n\n---\n\n")
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
bootstrapFiles := []string{
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"IDENTITY.md",
|
||||
// BootstrapFileInfo describes a resolved bootstrap file.
|
||||
type BootstrapFileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"` // empty = not found
|
||||
Scope string `json:"scope"` // "project" or "global"
|
||||
}
|
||||
|
||||
// bootstrapFileSpec defines the search scope for each bootstrap file.
|
||||
type bootstrapFileSpec struct {
|
||||
Name string
|
||||
Scope string // "project" = workDir→planWorkDir→workspace, "global" = workspace only
|
||||
}
|
||||
|
||||
var bootstrapSpecs = []bootstrapFileSpec{
|
||||
{Name: "AGENTS.md", Scope: "project"},
|
||||
{Name: "IDENTITY.md", Scope: "project"},
|
||||
{Name: "SOUL.md", Scope: "global"},
|
||||
{Name: "USER.md", Scope: "global"},
|
||||
}
|
||||
|
||||
// bootstrapProjectDirs returns de-duplicated search directories for project-scoped files.
|
||||
func (cb *ContextBuilder) bootstrapProjectDirs() []string {
|
||||
seen := map[string]bool{}
|
||||
var dirs []string
|
||||
for _, d := range []string{cb.workDir, cb.memory.GetPlanWorkDir(), cb.workspace} {
|
||||
if d != "" && !seen[d] {
|
||||
seen[d] = true
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
projectDirs := cb.bootstrapProjectDirs()
|
||||
|
||||
var sb strings.Builder
|
||||
for _, filename := range bootstrapFiles {
|
||||
filePath := filepath.Join(cb.workspace, filename)
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
|
||||
for _, spec := range bootstrapSpecs {
|
||||
var dirs []string
|
||||
if spec.Scope == "global" {
|
||||
dirs = []string{cb.workspace}
|
||||
} else {
|
||||
dirs = projectDirs
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
filePath := filepath.Join(dir, spec.Name)
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", spec.Name, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ResolveBootstrapPaths returns path resolution info for each bootstrap file
|
||||
// using the same search logic as LoadBootstrapFiles.
|
||||
func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo {
|
||||
projectDirs := cb.bootstrapProjectDirs()
|
||||
|
||||
result := make([]BootstrapFileInfo, 0, len(bootstrapSpecs))
|
||||
for _, spec := range bootstrapSpecs {
|
||||
info := BootstrapFileInfo{Name: spec.Name, Scope: spec.Scope}
|
||||
var dirs []string
|
||||
if spec.Scope == "global" {
|
||||
dirs = []string{cb.workspace}
|
||||
} else {
|
||||
dirs = projectDirs
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
filePath := filepath.Join(dir, spec.Name)
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
info.Path = filePath
|
||||
break
|
||||
}
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessages(
|
||||
history []providers.Message,
|
||||
summary string,
|
||||
|
|
|
|||
|
|
@ -811,6 +811,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||
// 1. Update tool contexts
|
||||
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
|
||||
|
||||
// 1a. Set session-specific working directory for bootstrap file lookup
|
||||
agent.ContextBuilder.SetWorkDir(agent.EffectiveWorkspace(opts.SessionKey))
|
||||
|
||||
// 1b. Inject peer session awareness into system prompt
|
||||
projectPath := agent.ContextBuilder.GetPlanWorkDir()
|
||||
if projectPath == "" {
|
||||
|
|
@ -2456,6 +2459,19 @@ func (al *AgentLoop) GetSessionStats() *stats.Stats {
|
|||
return &s
|
||||
}
|
||||
|
||||
// GetContextInfo returns the bootstrap file resolution and directory context for the default agent.
|
||||
func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, bootstrap []BootstrapFileInfo) {
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return "", "", "", nil
|
||||
}
|
||||
workspace = agent.Workspace
|
||||
planWorkDir = agent.ContextBuilder.GetPlanWorkDir()
|
||||
workDir = agent.ContextBuilder.workDir
|
||||
bootstrap = agent.ContextBuilder.ResolveBootstrapPaths()
|
||||
return
|
||||
}
|
||||
|
||||
// GetSystemPrompt returns the system prompt last sent to the LLM.
|
||||
// Falls back to building from current state if no LLM call has occurred yet.
|
||||
func (al *AgentLoop) GetSystemPrompt() string {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,21 @@ type GitChange struct {
|
|||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// BootstrapFileInfo describes a resolved bootstrap file for the context API.
|
||||
type BootstrapFileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// ContextInfo describes the agent's directory context and bootstrap file resolution.
|
||||
type ContextInfo struct {
|
||||
WorkDir string `json:"work_dir"`
|
||||
PlanWorkDir string `json:"plan_work_dir"`
|
||||
Workspace string `json:"workspace"`
|
||||
Bootstrap []BootstrapFileInfo `json:"bootstrap"`
|
||||
}
|
||||
|
||||
// DataProvider is the read-only interface to agent state for the Mini App API.
|
||||
type DataProvider interface {
|
||||
ListSkills() []skills.SkillInfo
|
||||
|
|
@ -106,6 +121,8 @@ type DataProvider interface {
|
|||
GetActiveSessions() []SessionInfo
|
||||
GetGitRepos() []GitRepoSummary
|
||||
GetGitRepoDetail(name string) GitInfo
|
||||
GetContextInfo() ContextInfo
|
||||
GetSystemPrompt() string
|
||||
}
|
||||
|
||||
// CommandSender injects a command into the message bus on behalf of a user.
|
||||
|
|
@ -516,6 +533,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
||||
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
||||
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
||||
mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext))
|
||||
mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt))
|
||||
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
||||
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
||||
|
|
@ -604,6 +623,14 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, s)
|
||||
}
|
||||
|
||||
func (h *Handler) apiContext(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, h.provider.GetContextInfo())
|
||||
}
|
||||
|
||||
func (h *Handler) apiPrompt(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"prompt": h.provider.GetSystemPrompt()})
|
||||
}
|
||||
|
||||
func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
||||
repo := r.URL.Query().Get("repo")
|
||||
if repo == "" {
|
||||
|
|
@ -759,7 +786,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|||
ch := h.notifier.Subscribe()
|
||||
defer h.notifier.Unsubscribe(ch)
|
||||
|
||||
var lastPlan, lastSession, lastSkills, lastDev []byte
|
||||
var lastPlan, lastSession, lastSkills, lastDev, lastContext []byte
|
||||
|
||||
// Send initial state immediately
|
||||
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
||||
|
|
@ -768,6 +795,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|||
&lastSession)
|
||||
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
||||
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
|
||||
|
||||
for {
|
||||
select {
|
||||
|
|
@ -782,6 +810,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|||
&lastSession)
|
||||
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
||||
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,12 @@ func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
|
|||
func (m *mockDataProvider) GetGitRepoDetail(name string) GitInfo {
|
||||
return GitInfo{Name: name}
|
||||
}
|
||||
func (m *mockDataProvider) GetContextInfo() ContextInfo {
|
||||
return ContextInfo{Workspace: "/mock/workspace"}
|
||||
}
|
||||
func (m *mockDataProvider) GetSystemPrompt() string {
|
||||
return "mock system prompt"
|
||||
}
|
||||
|
||||
type mockSender struct{}
|
||||
|
||||
|
|
@ -391,8 +397,8 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
|||
defer resp.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// Drain initial 4 events (plan, session, skills, dev)
|
||||
drainEvents(t, scanner, 4, 2*time.Second)
|
||||
// Drain initial 5 events (plan, session, skills, dev, context)
|
||||
drainEvents(t, scanner, 5, 2*time.Second)
|
||||
|
||||
// Mutate state and notify — diff dedup should detect the change and send a new event
|
||||
provider.mutated.Store(true)
|
||||
|
|
@ -420,8 +426,8 @@ func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
|
|||
defer resp.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// Drain initial events (plan, session, skills, dev)
|
||||
drainEvents(t, scanner, 4, 2*time.Second)
|
||||
// Drain initial events (plan, session, skills, dev, context)
|
||||
drainEvents(t, scanner, 5, 2*time.Second)
|
||||
|
||||
// Notify with unchanged data — should produce zero new event lines
|
||||
notifier.Notify()
|
||||
|
|
@ -474,6 +480,12 @@ func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
|
|||
func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
|
||||
return GitInfo{Name: name}
|
||||
}
|
||||
func (m *mutatingDataProvider) GetContextInfo() ContextInfo {
|
||||
return ContextInfo{Workspace: "/mock/workspace"}
|
||||
}
|
||||
func (m *mutatingDataProvider) GetSystemPrompt() string {
|
||||
return "mock system prompt"
|
||||
}
|
||||
|
||||
// ── Dev proxy tests ──
|
||||
|
||||
|
|
@ -1678,8 +1690,8 @@ func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
|
|||
defer resp.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// Drain initial 4 events
|
||||
drainEvents(t, scanner, 4, 2*time.Second)
|
||||
// Drain initial 5 events
|
||||
drainEvents(t, scanner, 5, 2*time.Second)
|
||||
|
||||
// Activate — should trigger a dev event with active=true
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
|
|||
|
|
@ -797,6 +797,7 @@
|
|||
<div id="session" class="panel">
|
||||
<div class="loading" id="session-loading">Loading session...</div>
|
||||
<div id="session-content" class="hidden"></div>
|
||||
<div id="context-content"></div>
|
||||
</div>
|
||||
|
||||
<div id="config" class="panel">
|
||||
|
|
@ -1310,15 +1311,79 @@ function renderSessionFromData(sessions, stats) {
|
|||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
var cachedContextInfo = null;
|
||||
|
||||
function renderContextCard(ctx) {
|
||||
if (!ctx) return '';
|
||||
cachedContextInfo = ctx;
|
||||
var wd = ctx.work_dir || '\u2014';
|
||||
var pwd = ctx.plan_work_dir || '\u2014';
|
||||
var ws = ctx.workspace || '\u2014';
|
||||
var filesHtml = '';
|
||||
if (ctx.bootstrap && ctx.bootstrap.length) {
|
||||
filesHtml = ctx.bootstrap.map(function(b) {
|
||||
var path = b.path ? escapeHtml(b.path) : '\u2014';
|
||||
var scope = b.scope === 'global' ? 'global' : 'project';
|
||||
var found = b.path ? 'var(--text)' : 'var(--hint)';
|
||||
return `<div style="display:flex;gap:8px;padding:2px 0;font-size:12px">
|
||||
<span style="min-width:90px;font-weight:600;color:${found}">${escapeHtml(b.name)}</span>
|
||||
<span style="color:var(--hint);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${path}">${path}</span>
|
||||
<span style="color:var(--hint);font-size:11px">${scope}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
return `<div class="card glass">
|
||||
<div class="card-title">Context</div>
|
||||
<div style="font-size:12px">
|
||||
<div class="stat-row"><span class="stat-label">workDir</span><span class="stat-value" style="font-size:12px;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(wd)}">${escapeHtml(wd)}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">planWorkDir</span><span class="stat-value" style="font-size:12px;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(pwd)}">${escapeHtml(pwd)}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">workspace</span><span class="stat-value" style="font-size:12px;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(ws)}">${escapeHtml(ws)}</span></div>
|
||||
</div>
|
||||
<div style="margin-top:8px">${filesHtml}</div>
|
||||
<div style="margin-top:8px;text-align:center">
|
||||
<button onclick="toggleSystemPrompt()" style="background:var(--secondary-bg);color:var(--text);border:none;padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer" id="prompt-toggle-btn">Show System Prompt</button>
|
||||
</div>
|
||||
<pre id="system-prompt-view" style="display:none;margin-top:8px;font-size:11px;max-height:400px;overflow:auto;background:var(--secondary-bg);padding:8px;border-radius:6px;white-space:pre-wrap;word-break:break-word"></pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleSystemPrompt() {
|
||||
var view = document.getElementById('system-prompt-view');
|
||||
var btn = document.getElementById('prompt-toggle-btn');
|
||||
if (!view || !btn) return;
|
||||
if (view.style.display === 'none') {
|
||||
btn.textContent = 'Loading...';
|
||||
apiFetch('/miniapp/api/prompt').then(function(data) {
|
||||
view.textContent = data.prompt || '(empty)';
|
||||
view.style.display = 'block';
|
||||
btn.textContent = 'Hide System Prompt';
|
||||
}).catch(function() {
|
||||
btn.textContent = 'Show System Prompt';
|
||||
});
|
||||
} else {
|
||||
view.style.display = 'none';
|
||||
btn.textContent = 'Show System Prompt';
|
||||
}
|
||||
}
|
||||
|
||||
function renderContextFromData(ctx) {
|
||||
var el = document.getElementById('context-content');
|
||||
if (el) el.innerHTML = renderContextCard(ctx);
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
return loadTab('session-loading', 'session-content', 'session',
|
||||
function() {
|
||||
return Promise.all([
|
||||
apiFetch('/miniapp/api/session'),
|
||||
apiFetch('/miniapp/api/sessions').catch(function() { return []; }),
|
||||
apiFetch('/miniapp/api/context').catch(function() { return null; }),
|
||||
]);
|
||||
},
|
||||
function(results) { renderSessionFromData(results[1], results[0]); });
|
||||
function(results) {
|
||||
renderSessionFromData(results[1], results[0]);
|
||||
renderContextFromData(results[2]);
|
||||
});
|
||||
}
|
||||
|
||||
function formatTokens(n) {
|
||||
|
|
@ -1537,6 +1602,9 @@ function connectSSE() {
|
|||
eventSource.addEventListener('dev', function(e) {
|
||||
try { lastSSE.dev = Date.now(); renderDevFromData(JSON.parse(e.data)); } catch(err) {}
|
||||
});
|
||||
eventSource.addEventListener('context', function(e) {
|
||||
try { renderContextFromData(JSON.parse(e.data)); } catch(err) {}
|
||||
});
|
||||
eventSource.onerror = function() {
|
||||
// Browser will auto-reconnect EventSource
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue