System Prompt Plan¶
Goal¶
Give Keen Code a system prompt that is assembled at call time from three layers:
- Static identity and behaviour — who Keen is, how it writes, what it never does
- Dynamic environment block — working directory, OS, date, git status, top-level directory listing
- Project instructions — contents of
AGENTS.md(orCLAUDE.md) found by walking up fromworkingDir
The result is a single llm.RoleSystem message prepended to every StreamChat call.
Why These Three Layers¶
| Layer | What the model learns immediately | Reference |
|---|---|---|
| Static | Identity, tone, tool rules, safety limits, git rules | opencode anthropic.txt / qwen.txt |
| Dynamic env | Where it is, what OS, today's date, is it a git repo, project shape | opencode system.ts environment() |
| Project instructions | Build commands, test commands, coding style, team conventions | kimi-cli ${KIMI_AGENTS_MD} / opencode InstructionPrompt.system() |
Without the env block the model has to call a tool just to find out where it is. Without the project instructions block, users who maintain an AGENTS.md get no benefit from it. Without a static identity block the model has no baseline behaviour rule to fall back on.
New Files¶
configs/prompts/
systemprompt.go ← builder: Build(workingDir) string
systemprompt_test.go ← unit tests for all three layers
No other packages are introduced. The prompt is self-contained inside the prompts package.
systemprompt.go — Full Design¶
Package-level constants¶
package llm
// staticPrompt is the fixed identity and behaviour section.
// It is embedded at compile time so there is no runtime file I/O.
const staticPrompt = `You are Keen Code, an expert coding agent running in your terminal.
...`
Keeping it as a Go const (not a file embed) is the simplest approach for now. It avoids a //go:embed dependency and keeps the single responsibility clear: this file builds the prompt, nothing else.
Build function — the only public surface¶
// Build assembles the full system prompt for a session.
// workingDir is the directory keen was launched from.
func Build(workingDir string) string
Called once per StreamChat invocation from AppState.StreamChat.
Internal helpers¶
// envBlock returns the <env>…</env> XML block.
func envBlock(workingDir string) string
// dirListing returns a compact top-level listing of workingDir (≤ 40 entries).
// Returns "" if the directory cannot be read.
func dirListing(workingDir string) string
// projectInstructions walks up from workingDir looking for AGENTS.md or CLAUDE.md.
// Returns "" if neither file is found in the entire upward path.
func projectInstructions(workingDir string) string
// isGitRepo checks whether workingDir is inside a git repository.
func isGitRepo(workingDir string) bool
// findUpward walks from dir toward the filesystem root, stopping at the first
// file whose base name matches any of the provided candidates.
// Returns ("", "") if not found.
func findUpward(dir string, candidates []string) (path string, content string)
Static Prompt Content¶
The static section is the largest part. Here is the exact text, broken into named sections so the model weights them correctly:
You are Keen Code, an expert coding agent running in terminal environment.
You help with software engineering tasks: fixing bugs, writing new features,
refactoring code, explaining code, exploring codebases, writing tests, and more.
# Tone and style
- Be concise and direct. Output is displayed on a CLI in a monospace font.
Use GitHub-flavored markdown.
- No emojis unless the user explicitly asks for them.
- No unnecessary preamble or postamble. Do not summarise what you just did.
Do not explain a code block you are about to write.
- One-word or one-line answers are fine when that is all the question needs.
- Never use bash or code comments as a communication channel — write to the
user in your response text only.
# Doing tasks
- Explore before acting. Use grep/glob/read_file to understand the codebase
before making changes.
- Follow existing conventions: mimic the style, naming, and patterns already
in the project.
- Never assume a library is available. Check go.mod, package.json, pom.xml, or the
relevant manifest before writing code that uses a dependency.
- Make minimal changes. Prefer editing an existing file to creating a new one.
- Verify your work. After making changes, run the project's test command if
you know it. If you do not know it, check AGENTS.md, the README.md, or ask.
# Tool usage
- Prefer specialised tools over bash for file operations:
read_file → reading file contents
write_file → creating new files
edit_file → modifying existing files
glob → listing files by pattern
grep → searching file contents
bash → shell commands that have no dedicated tool
- Run independent tool calls in parallel where possible.
- Reference code as `file_path:line_number` so the user can jump straight
to the source.
# Git rules
- Never run git commit, git push, git reset, or git rebase unless the user
explicitly asks you to.
# Safety
- Never introduce code that logs, exposes, or commits secrets or API keys.
- Refuse requests to write malicious code, even framed as educational.
- Before working on a file, consider what the code is supposed to do. If it
looks malicious, refuse.
Rationale for each section:
Tone and style— prevents the most common LLM anti-patterns in CLI contexts: verbosity, emoji spam, self-narration. Maps directly to opencodeqwen.txt"minimize output tokens" and "no preamble/postamble".Doing tasks— the explore-first, minimal-changes, follow-conventions workflow that makes the agent actually useful on real codebases. From opencodeqwen.txt"Following conventions" and kimi-cli "Make MINIMAL changes".Tool usage— makes the agent use the right tool for the right job and run parallel calls. From opencode "use dedicated tools" policy and kimi-cli "HIGHLY RECOMMENDED to make them in parallel".Git rules— the most-violated safety rule in every coding agent. From kimi-cli "DO NOT run git commit…" and opencode "NEVER commit unless user explicitly asks".Safety— refusal rules for malicious code. From opencodeqwen.txt"IMPORTANT: Refuse to write code … that may be used maliciously".
Dynamic Environment Block¶
Assembled at call time using runtime.GOOS, os.Getwd-style resolution, time.Now(), and a small exec.Command("git", "rev-parse", ...) check.
<env>
Working directory: /Users/alice/projects/my-api
Platform: darwin
Today's date: 2025-07-15
Is git repo: yes
</env>
Top-level project structure:
Design decisions:
- XML-tag wrapping (
<env>) follows opencode's convention. It gives the model a clear anchor to reference. - Directory listing is capped at 40 entries (top level only, no recursion) to avoid token bloat. kimi-cli does a flat
lsoutput; opencode comments out a deeper tree (ripgrep --tree) because it is too large. 40 top-level entries is a safe middle ground. - The listing is produced with
os.ReadDir(workingDir), which is fast and needs no shell. Directories get a trailing/. - If
workingDircannot be read, the directory listing section is omitted entirely — the rest of the prompt is still valid. - The
isGitRepocheck usesexec.Command("git", "rev-parse", "--is-inside-work-tree")— one tiny subprocess, no library dependency.
Project Instructions Block (AGENTS.md loading)¶
# Project Instructions (from /Users/alice/projects/my-api/AGENTS.md)
## Keen Code
CLI-based coding agent powered by AI using Firebase Genkit for LLM interactions.
## Architecture
...
## Important Guidelines
- Minimal comments — Only when strictly necessary
- Test critical paths — Not aiming for 100% coverage
- Always run the tests — After each change
findUpward algorithm:
start: workingDir (e.g. /Users/alice/projects/my-api)
check: /Users/alice/projects/my-api/AGENTS.md → found → done
or
check: /Users/alice/projects/my-api/AGENTS.md → not found
check: /Users/alice/projects/AGENTS.md → not found
check: /Users/alice/AGENTS.md → found → done
stop at filesystem root or home directory
- Candidate file names searched in order:
AGENTS.md,CLAUDE.md - The walk stops at the filesystem root — it will not read
/AGENTS.md - If the file is found but cannot be read (permissions), the section is silently omitted
- If the file content is empty, the section is omitted
Why walk upward?
A user may cd into a subdirectory of their project. The AGENTS.md is at the repo root. Walking upward finds it without requiring the user to always launch from the root. Both opencode (InstructionPrompt.systemPaths + Filesystem.findUp) and kimi-cli (${KIMI_AGENTS_MD}) use this pattern.
Size guard:
If AGENTS.md exceeds 8 KB, include only the first 8 KB and append a note: [truncated — full file at <path>]. This prevents a single large AGENTS.md from dominating the context window.
Integration Point — AppState.StreamChat¶
// internal/cli/repl/state.go
func (s *AppState) StreamChat(ctx context.Context, cfg *config.ResolvedConfig) (<-chan llm.StreamEvent, error) {
if s.llmClient == nil {
return nil, nil
}
systemMsg := llm.Message{
Role: llm.RoleSystem,
Content: llm.Build(s.workingDir),
}
messages := append([]llm.Message{systemMsg}, s.GetMessages()...)
return s.llmClient.StreamChat(ctx, messages, s.toolRegistry)
}
Required change to AppState:
AppState currently holds no workingDir. Add it:
type AppState struct {
messages []llm.Message
llmClient llm.LLMClient
toolRegistry *tools.Registry
workingDir string // ← new field
}
func NewAppState(client llm.LLMClient, workingDir string) *AppState {
return &AppState{
messages: []llm.Message{},
llmClient: client,
toolRegistry: tools.NewRegistry(),
workingDir: workingDir,
}
}
workingDir is already available in initialModel via ctx.workingDir — it is passed to setupToolRegistry today. Adding it to AppState requires a one-line change at the call site in repl.go:
// internal/cli/repl/repl.go initialModel()
appState := NewAppState(llmClient, ctx.workingDir) // was: NewAppState(llmClient)
The system message is not stored in AppState.messages. It is prepended fresh on every StreamChat call. This means:
- ClearMessages() does not accidentally wipe the system prompt
- The env block (today's date, directory listing) stays current if a session runs across midnight or the user changes directory
- The AGENTS.md content reflects any edits the agent itself may have made during the session
systemprompt_test.go — Test Coverage¶
| Test | What it checks |
|---|---|
TestBuild_ContainsIdentity |
Output contains "Keen Code" |
TestBuild_ContainsWorkingDir |
Output contains the workingDir path |
TestBuild_ContainsPlatform |
Output contains runtime.GOOS |
TestBuild_ContainsDate |
Output contains today's date in 2006-01-02 format |
TestBuild_GitRepo |
When a .git dir is present, env block says yes |
TestBuild_NoGitRepo |
When no .git dir, env block says no |
TestBuild_DirListing |
Output contains known files from a temp dir |
TestBuild_DirListing_Empty |
Empty dir produces no listing section (no panic) |
TestBuild_DirListing_Unreadable |
Unreadable dir produces no listing section (no panic) |
TestBuild_AgentsMd_Found |
AGENTS.md in workingDir appears in output |
TestBuild_AgentsMd_WalkUp |
AGENTS.md one level up is found |
TestBuild_ClaudeMd_Fallback |
CLAUDE.md used when no AGENTS.md exists |
TestBuild_NoInstructionFile |
No file found → no project instructions section |
TestBuild_AgentsMd_Truncation |
File > 8 KB is truncated with note |
TestBuild_AgentsMd_Empty |
Empty file → section omitted |
TestBuild_SystemMessage_NotStored |
AppState.GetMessages() never contains a system role entry |
TestBuild_FreshOnEachCall |
Two calls to Build with same args produce identical output structure |
File Changeset¶
| File | Change |
|---|---|
internal/llm/systemprompt.go |
New — Build(), envBlock(), dirListing(), projectInstructions(), findUpward(), isGitRepo(), staticPrompt const |
internal/llm/systemprompt_test.go |
New — full test suite (see table above) |
internal/cli/repl/state.go |
Edit — add workingDir string field to AppState; update NewAppState signature; update StreamChat to prepend system message |
internal/cli/repl/state_test.go |
Edit — update NewAppState call sites |
internal/cli/repl/repl.go |
Edit — pass ctx.workingDir to NewAppState |
No changes to any LLM client files (openai.go, genkit.go, openai_responses.go). The system message is just another llm.Message with RoleSystem — all three clients already handle it via their existing toOpenAIMessages / toGenkitMessages / toOpenAIResponseInput converters.
Prompt Assembly Order¶
[system message content]
─────────────────────────────────
1. staticPrompt (identity, tone, tasks, tools, git, safety)
2. "\n\n" + envBlock() (<env> working dir, platform, date, git flag </env>
+ top-level directory listing)
3. "\n\n" + projectInstructions() (# Project Instructions … AGENTS.md content)
─────────────────────────────────
[user / assistant message history]
Token Budget Estimate¶
| Section | Typical tokens |
|---|---|
| Static prompt | ~450 |
| Env block | ~60 |
| Dir listing (40 entries) | ~120 |
| AGENTS.md (8 KB max) | ~2 000 |
| Total | ~2 600 |
At 200 k context (DeepSeek / Kimi), this is ~1.3 % of the window. Negligible.
What This Does Not Include (Deferred)¶
- Per-model routing — a single prompt works well enough for all providers currently in keen-code. Add model-specific variants only once a concrete behavioural difference is observed.
- Global
AGENTS.md— reading from~/.keen/AGENTS.md(opencode-style global instructions). Useful but not required for the first iteration. <system-reminder>injection — injecting mid-conversation reminders (opencode plan mode). Deferred to a future plan-mode feature.- Subdirectory
AGENTS.mdfiles — only the first file found walking upward is used. Loading multiple nested files adds complexity with marginal benefit at this stage.