Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func newPromptSources(extra ...SectionSource) []promptSectionSource {
corePromptSource{},
&projectRulesSource{},
taskStateSource{},
planModeContextSource{},
todosSource{},
skillPromptSource{},
}
Expand Down
68 changes: 68 additions & 0 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,51 @@ func TestDefaultBuilderBuildIncludesTaskStateBeforeSystemState(t *testing.T) {
}
}

func TestDefaultBuilderBuildIncludesPlanSections(t *testing.T) {
t.Parallel()

builder := NewBuilder()
got, err := builder.Build(stdcontext.Background(), BuildInput{
AgentMode: agentsession.AgentModePlan,
PlanStage: "plan",
CurrentPlan: &agentsession.PlanArtifact{
ID: "plan-1",
Revision: 3,
Status: agentsession.PlanStatusDraft,
Spec: agentsession.PlanSpec{
Goal: "引入 plan/build 模式",
Steps: []string{"扩展 session", "扩展 runtime"},
Constraints: []string{"保持 tools 边界"},
Verify: []string{"go test ./internal/..."},
},
Summary: agentsession.SummaryView{
Goal: "引入 plan/build 模式",
KeySteps: []string{"扩展 session", "扩展 runtime"},
Constraints: []string{"保持 tools 边界"},
Verify: []string{"go test ./internal/..."},
ActiveTodoIDs: []string{"todo-1"},
},
},
Metadata: testMetadata(t.TempDir()),
InjectFullPlan: true,
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if !strings.Contains(got.SystemPrompt, "## Plan Mode") {
t.Fatalf("expected plan mode section, got %q", got.SystemPrompt)
}
if !strings.Contains(got.SystemPrompt, "You are currently in the planning stage.") {
t.Fatalf("expected plan mode prompt asset content, got %q", got.SystemPrompt)
}
if !strings.Contains(got.SystemPrompt, "## Current Plan") {
t.Fatalf("expected current plan section, got %q", got.SystemPrompt)
}
if !strings.Contains(got.SystemPrompt, "full_plan_view:") {
t.Fatalf("expected full plan view in prompt, got %q", got.SystemPrompt)
}
}

func TestDefaultBuilderBuildIncludesTodosBeforeSystemState(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -175,6 +220,29 @@ func TestDefaultBuilderBuildIncludesTodosBeforeSystemState(t *testing.T) {
}
}

func TestNewBuilderWithMemoAndSummarizersIncludesMemoSection(t *testing.T) {
t.Parallel()

builder := NewBuilderWithMemoAndSummarizers(nil, nil, stubPromptSectionSource{
sections: []promptSection{
NewPromptSection("memo", "remember this"),
},
})

got, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: []providertypes.Message{
{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
},
Metadata: testMetadata(t.TempDir()),
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if !strings.Contains(got.SystemPrompt, "## memo") {
t.Fatalf("expected memo section in prompt, got %q", got.SystemPrompt)
}
}

func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T) {
t.Parallel()

Expand Down
102 changes: 102 additions & 0 deletions internal/context/source_plan_mode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package context

import (
"context"
"fmt"
"strings"

"neo-code/internal/promptasset"
agentsession "neo-code/internal/session"
)

// planModeContextSource injects plan/build mode guidance plus the current plan projection.
type planModeContextSource struct{}

// Sections renders the relevant mode guidance and optional current plan section.
func (planModeContextSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) {
if err := ctx.Err(); err != nil {
return nil, err
}

mode := agentsession.NormalizeAgentMode(input.AgentMode)
stage := strings.TrimSpace(input.PlanStage)
if stage == "" {
return nil, nil
}

sections := make([]promptSection, 0, 2)
modeSection := renderPlanModeSection(mode, stage)
if modeSection.Content != "" {
sections = append(sections, modeSection)
}

if input.CurrentPlan == nil {
return sections, nil
}
planSection := renderCurrentPlanSection(input.CurrentPlan, input.InjectFullPlan)
if planSection.Content != "" {
sections = append(sections, planSection)
}
return sections, nil
}

func renderPlanModeSection(mode agentsession.AgentMode, stage string) promptSection {
lines := make([]string, 0, 4)
lines = append(lines, fmt.Sprintf("current_mode: %q", mode))
lines = append(lines, fmt.Sprintf("current_stage: %q", stage))
if content := strings.TrimSpace(promptasset.PlanModePrompt(stage)); content != "" {
lines = append(lines, content)
}
return promptSection{
Title: "Plan Mode",
Content: strings.Join(lines, "\n"),
}
}

func renderCurrentPlanSection(plan *agentsession.PlanArtifact, injectFull bool) promptSection {
if plan == nil {
return promptSection{}
}
lines := make([]string, 0, 16)
lines = append(lines,
fmt.Sprintf("plan_id: %q", strings.TrimSpace(plan.ID)),
fmt.Sprintf("revision: %d", plan.Revision),
fmt.Sprintf("status: %q", agentsession.NormalizePlanStatus(plan.Status)),
)
if goal := strings.TrimSpace(plan.Summary.Goal); goal != "" {
lines = append(lines, fmt.Sprintf("goal: %q", goal))
}
if len(plan.Summary.KeySteps) > 0 {
lines = append(lines, "key_steps:")
for _, step := range plan.Summary.KeySteps {
lines = append(lines, "- "+step)
}
}
if len(plan.Summary.Constraints) > 0 {
lines = append(lines, "constraints:")
for _, constraint := range plan.Summary.Constraints {
lines = append(lines, "- "+constraint)
}
}
if len(plan.Summary.Verify) > 0 {
lines = append(lines, "verify:")
for _, check := range plan.Summary.Verify {
lines = append(lines, "- "+check)
}
}
if len(plan.Summary.ActiveTodoIDs) > 0 {
lines = append(lines, "active_todo_ids:")
for _, todoID := range plan.Summary.ActiveTodoIDs {
lines = append(lines, "- "+todoID)
}
}
if injectFull {
if full := strings.TrimSpace(agentsession.RenderPlanContent(plan.Spec)); full != "" {
lines = append(lines, "", "full_plan_view:", full)
}
}
return promptSection{
Title: "Current Plan",
Content: strings.Join(lines, "\n"),
}
}
4 changes: 4 additions & 0 deletions internal/context/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ type BuildInput struct {
Messages []providertypes.Message
TaskState agentsession.TaskState
Todos []agentsession.TodoItem
AgentMode agentsession.AgentMode
PlanStage string
CurrentPlan *agentsession.PlanArtifact
InjectFullPlan bool
ActiveSkills []skills.Skill
RepositorySummary *RepositorySummarySection
Repository RepositoryContext
Expand Down
16 changes: 16 additions & 0 deletions internal/promptasset/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ var repeatCycleReminder = mustReadTemplate("templates/runtime/self_healing_repea

var compactSystemPromptTemplate = mustReadTemplate("templates/context/compact_system_prompt.md")

var planModePlanPrompt = mustReadTemplate("templates/context/plan_mode_plan.md")

var planModeBuildExecutePrompt = mustReadTemplate("templates/context/plan_mode_build_execute.md")

var researcherRolePrompt = mustReadTemplate("templates/subagent/researcher.md")

var coderRolePrompt = mustReadTemplate("templates/subagent/coder.md")
Expand Down Expand Up @@ -58,6 +62,18 @@ func CompactSystemPrompt(taskStateContract string, summaryFormat string) string
return strings.TrimSpace(replacer.Replace(compactSystemPromptTemplate))
}

// PlanModePrompt 返回 plan/build 不同阶段对应的静态提示模板,供 context source 注入动态字段前复用。
func PlanModePrompt(stage string) string {
switch strings.TrimSpace(stage) {
case "plan":
return planModePlanPrompt
case "build_execute":
return planModeBuildExecutePrompt
default:
return ""
}
}

// ResearcherRolePrompt 返回 researcher 子代理基础 prompt。
func ResearcherRolePrompt() string {
return researcherRolePrompt
Expand Down
31 changes: 31 additions & 0 deletions internal/promptasset/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ func TestRuntimeReminderTemplates(t *testing.T) {
}
}

func TestPlanModePromptTemplates(t *testing.T) {
t.Parallel()

tests := []struct {
name string
stage string
want string
}{
{name: "plan", stage: "plan", want: "planning stage"},
{name: "build execute", stage: "build_execute", want: "build execution"},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
prompt := PlanModePrompt(tt.stage)
if strings.TrimSpace(prompt) == "" {
t.Fatalf("PlanModePrompt(%q) should not be empty", tt.stage)
}
if !strings.Contains(prompt, tt.want) {
t.Fatalf("PlanModePrompt(%q) = %q, want substring %q", tt.stage, prompt, tt.want)
}
})
}

if got := PlanModePrompt("unknown"); got != "" {
t.Fatalf("PlanModePrompt(unknown) = %q, want empty", got)
}
}

func joinCoreSectionContent() string {
sections := CoreSections()
parts := make([]string, 0, len(sections))
Expand Down
11 changes: 11 additions & 0 deletions internal/promptasset/templates/context/plan_mode_build_execute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
You are currently in build execution.

- Execute the task directly.
- If a current plan summary is attached, use it as guidance by default.
- If the summary is insufficient for the current task, consult the attached full plan view when available.
- If no current plan is attached, continue using task state, todos, and the conversation context.
- Small necessary deviations are allowed, but explain why they are needed.
- Do not create or rewrite the current full plan in this stage.
- If the current plan appears outdated, explain the mismatch and continue, or recommend switching back to planning.
- Do not output `plan_spec` or `summary_candidate` in build execution.
- When you believe the task tied to the current plan is complete, start your reply with a JSON object of the form `{"task_completion":{"completed":true}}`, then continue with the normal user-facing completion message.
9 changes: 9 additions & 0 deletions internal/promptasset/templates/context/plan_mode_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
You are currently in the planning stage.

- You may research, analyze, ask clarifying questions, and produce a plan.
- Do not perform any write action in this stage.
- Do not rewrite the current full plan unless the conversation clearly requires creating or replacing the plan itself.
- If you are only answering questions, comparing options, clarifying constraints, or refining details, do not output planning JSON.
- Only output a JSON object containing `plan_spec` and `summary_candidate` when you are explicitly creating or rewriting the current full plan.
- `plan_spec` must include `goal`, `steps`, `constraints`, `verify`, `todos`, and `open_questions`.
- `summary_candidate` must include `goal`, `key_steps`, `constraints`, `verify`, and `active_todo_ids`.
3 changes: 3 additions & 0 deletions internal/runtime/budget_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type TurnBudgetSnapshot struct {
CompactCount int
NoProgressStreakLimit int
RepeatCycleStreakLimit int
InjectFullPlan bool
Request providertypes.GenerateRequest
}

Expand Down Expand Up @@ -62,6 +63,7 @@ func newTurnBudgetSnapshot(
compactCount int,
noProgressStreakLimit int,
repeatCycleStreakLimit int,
injectFullPlan bool,
request providertypes.GenerateRequest,
) TurnBudgetSnapshot {
if attemptSeq <= 0 {
Expand All @@ -82,6 +84,7 @@ func newTurnBudgetSnapshot(
CompactCount: compactCount,
NoProgressStreakLimit: noProgressStreakLimit,
RepeatCycleStreakLimit: repeatCycleStreakLimit,
InjectFullPlan: injectFullPlan,
Request: request,
}
}
Expand Down
6 changes: 6 additions & 0 deletions internal/runtime/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResul
if err != nil {
return CompactResult{}, err
}
if result.Applied && markCurrentPlanContextDirty(&session) {
session.UpdatedAt = time.Now()
if err := s.sessionStore.UpdateSessionState(ctx, sessionStateInputFromSession(session)); err != nil {
return CompactResult{}, err
}
}

return fromCompactResult(result), nil
}
Expand Down
10 changes: 8 additions & 2 deletions internal/runtime/compact_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ func extractJSONObject(text string) (string, error) {

// extractJSONObjectCandidate 从给定起点抽取平衡的 JSON 对象片段。
func extractJSONObjectCandidate(text string, start int) (string, error) {
candidate, _, err := extractJSONObjectCandidateRange(text, start)
return candidate, err
}

// extractJSONObjectCandidateRange 从给定起点抽取平衡 JSON 对象,并返回原始结束位置。
func extractJSONObjectCandidateRange(text string, start int) (string, int, error) {
depth := 0
inString := false
escaped := false
Expand Down Expand Up @@ -276,10 +282,10 @@ func extractJSONObjectCandidate(text string, start int) (string, error) {
case '}':
depth--
if depth == 0 {
return strings.TrimSpace(text[start : index+1]), nil
return strings.TrimSpace(text[start : index+1]), index + 1, nil
}
}
}

return "", errors.New("runtime: compact summary response contains an incomplete JSON object")
return "", 0, errors.New("runtime: compact summary response contains an incomplete JSON object")
}
1 change: 1 addition & 0 deletions internal/runtime/input_prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func (p sessionInputPreparer) Prepare(
RunID: strings.TrimSpace(input.RunID),
Parts: prepared.Parts,
Workdir: strings.TrimSpace(prepared.Workdir),
Mode: strings.TrimSpace(input.Mode),
},
SavedAssets: append([]agentsession.AssetMeta(nil), prepared.SavedAssets...),
}, nil
Expand Down
1 change: 1 addition & 0 deletions internal/runtime/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func (s *Service) executeToolCallWithPermission(ctx context.Context, input permi
Name: input.Call.Name,
Arguments: []byte(input.Call.Arguments),
Workdir: input.Workdir,
ReadOnly: input.State != nil && isReadOnlyPlanningStage(resolvePlanningStageForState(input.State)),
SessionID: input.SessionID,
TaskID: input.TaskID,
AgentID: input.AgentID,
Expand Down
Loading
Loading