
Peas Workshop Advanced Coach
- 9 installs
- Updated June 27, 2026
- mz038197/vanscoding-skills
Helps with ai & agent building tasks.
About
peas-workshop-advanced-coach is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- peas-workshop-advanced-coach
- AI & Agent Building
- AI-coding skill
Peas Workshop Advanced Coach by the numbers
- 9 all-time installs (skills.sh)
- Ranked #12,152 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mz038197/vanscoding-skills --skill peas-workshop-advanced-coachAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| Last updated | June 27, 2026 |
| Repository | mz038197/vanscoding-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
PEAS Workshop Bridge Coach
Purpose
This skill is a router / state machine for Agent Workshop WG-13~22.
- WG-13~21: after the teacher explains a challenge, ask whether the student wants direct implementation or guided clarification first.
- WG-22: route into contract-first split workflow. Do not use the normal direct/guided choice.
- Keep
SKILL.mdthin. Detailed requirements live in the current challenge card underreferences/bridge/.
Hard Rules
These rules override every bridge card:
- 「準備好了」 is not permission to edit code. It only starts progress scan and routing.
- Before implementation, determine
next_wgand read exactly one current card fromreferences/bridge/. - Before file edits, obey the current card's Handoff Card.
- WG-13~21 may edit only project-root
main.pyunless the current card explicitly allows copying missing project assets. - WG-22 may edit only project-root
agent_core.pyandmain.py. - Preserve student-owned values: nick/display name, persona wording, comments, and local path choices unless the current card says they violate the challenge.
- Do not copy reference files wholesale into answer files. References are structural checks, not shortcuts.
- Do not implement future WG requirements. Finish and verify the current WG first.
Required Inputs
Read these first:
1. references/peas-splash.md 2. references/wg_milestone_checklist.md 3. references/bridge/index.md 4. The routed card for the current next_wg
Read only when needed:
references/starter_main_wg21.py: source for missing WG-13~21 blocks or blankmain.py.references/reference_agent_core.pyandreferences/reference_main.py: WG-22 structure checks only.references/project_assets/: copy missingprompts/ortemplates/files only when a card requires them.references/implementation-log.md: logging format after successful verification.
Never use workspace-root challenges-agent-workshop.md as the active skill contract. It may inform lesson design, but bridge execution follows this skill's cards.
State Machine
splash
-> ready?
-> progress_scan
-> route(next_wg)
-> mode_select # WG-13~21 only
-> guided? # optional, WG-13~21 only
-> implement_current
-> verify_current
-> loop_or_done # complete after WG-22
-> doneWG-22 uses:
route(22)
-> wg22_context
-> contract_questions
-> six_columns
-> start_implementation?
-> split_implementation
-> verify_wg22Startup
First Visible Message
Show only:
1. PEAS · Workshop 進階教練 2. The splash layout from references/peas-splash.md 3. One short question asking whether the student is ready to begin
Do not run progress scan, copy files, show progress, or ask the first challenge question before the student says they are ready.
After 「準備好了」
Run progress_scan:
1. Check project-root main.py. 2. If main.py is missing or empty and agent_core.py is absent, copy references/starter_main_wg21.py to main.py; then set next_wg = 22. 3. If split files already exist, inspect whether WG-22 appears complete. 4. If split files exist but WG-22 is incomplete, set next_wg = 22 and enter WG-22 repair flow from references/bridge/wg22-split-core.md. 5. If WG-22 is complete, the advanced workshop is complete; offer verification, reflection, or the separate Dataset Streamlit Shell path if relevant. 6. Otherwise scan main.py using references/wg_milestone_checklist.md and compute next_wg. 7. Read references/bridge/index.md, then the card for next_wg.
Do not say internal terms like progress_scan, milestone, or file paths unless needed for troubleshooting.
Routing
| next_wg | Action |
|---|---|
| 13~21 | Show the current challenge title and ask the two-option choice below |
| 22 | Enter WG-22 contract-first flow from references/bridge/wg22-split-core.md |
| complete | Offer verification, reflection, or redo |
For WG-13~21, ask exactly one choice:
下一題是 WG-XX:<title>。
你想怎麼進行?
1. 直接實作:老師已講完,我依這題規格改 main.py 並驗收。
2. 先引導:我先用幾個問題幫你整理規格,再實作。If the student chooses direct implementation, implement the current card only.
If the student chooses guided clarification, ask concise questions for the current card only, then summarize a short handoff card and ask whether to implement.
WG-13~21 Implementation
Use the routed card as the active contract.
Required behavior:
- Edit only
main.py. - Use
starter_main_wg21.pyas a source for missing blocks, but merge into the student's existing file. - Do not blindly overwrite existing functions.
- Preserve nick/persona. If a function exists but needs new logic, add the missing logic around the preserved student-owned text.
- If an existing function differs too much to safely merge, stop and ask whether to replace that function with the starter version while preserving nick/persona.
- Verify the current WG using the routed card before asking whether to continue.
After verification:
WG-XX 已完成。要繼續下一題嗎?Do not automatically continue through multiple WG cards without explicit student confirmation.
WG-22 Contract-First Split
WG-22 is special. Follow references/bridge/wg22-split-core.md.
Required behavior:
- Do not offer the normal direct/guided choice.
- First explain context: WG-12~21 single-file agent, why splitting core from CLI matters, and expected unchanged CLI behavior.
- Ask 2a~2d′ one question at a time.
- Produce six-column contract and get confirmation.
- Ask once whether to start implementation.
- Only after explicit 「開始實作」 may edit
agent_core.pyandmain.py. - Split in steps and run the WG-22 verification checklist.
Project Assets
When WG-19 or later needs memory consolidation assets:
- Copy missing files from
references/project_assets/to project root. - Copy only missing files.
- Never overwrite existing
prompts/ortemplates/files. - Do not pre-copy runtime
memory/MEMORY.mdormemory/HISTORY.md.
Verification Commands
Use Windows-safe checks. Prefer Python one-liners or rg, not grep.
Examples:
python -c "import ast, pathlib; ast.parse(pathlib.Path('main.py').read_text(encoding='utf-8'))"
rg "def run_react_turn|def save_session_jsonl|input\\(" "main.py" "agent_core.py"
rg "Agent.from_env|agent.chat|ChatOpenAI|run_react_turn" "streamlit_app.py" "chainlit_app.py"
uv run main.pyFor interactive uv run main.py, a missing API key is acceptable only if the program reports it cleanly without a traceback.
Logging
After a challenge passes verification:
- Append a concise record to
session-records/peas-workshop-advanced-log.md. - Use
references/implementation-log.mdformat when practical. - Record: WG number, chosen mode, implementation summary, verification result, and any preserved student-specific settings.
Failure Recovery
- If the agent begins editing before the required mode/contract, stop and return to routing.
- If
main.pyis damaged during WG-13~21, preserve a backup before applying any starter-based replacement. - If WG-22 split fails, repair from the current WG-21
main.pyand the WG-22 card. Do not wholesale copy reference answer files. - If unsure which WG is next, ask a single clarification question rather than guessing.
Trigger Phrases
peas-workshop-advanced-coach, PEAS workshop 進階教練, Bridge Mode, WG-13, WG-22, 拆檔教練, Agent.chat, 核心與 CLI 分家.
Bridge Cards Index
Use this file after readiness_scan determines the next unfinished workshop challenge.
Routing
| next_wg | Card | Default mode |
|---|---|---|
| 13 | wg13-react-tools.md | Ask: direct implementation or guided clarification |
| 14 | wg14-workspace-tools.md | Ask: direct implementation or guided clarification |
| 15 | wg15-jsonl-write.md | Ask: direct implementation or guided clarification |
| 16 | wg16-jsonl-load.md | Ask: direct implementation or guided clarification |
| 17 | wg17-budget-trim.md | Ask: direct implementation or guided clarification |
| 18 | wg18-messages-for-model.md | Ask: direct implementation or guided clarification |
| 19 | wg19-memory-merge.md | Ask: direct implementation or guided clarification |
| 20 | wg20-skills.md | Ask: direct implementation or guided clarification |
| 21 | wg21-image.md | Ask: direct implementation or guided clarification |
| 22 | wg22-split-core.md | Contract-first flow; do not offer normal direct implementation |
Universal Bridge Rules
- Read only the card for the current
next_wgbefore implementation. - WG-13 through WG-21 modify only project-root
main.py. - WG-22 may modify only project-root
agent_core.pyandmain.py. - Preserve student-owned values: nick/display name, persona wording, comments, and local path choices unless they conflict with the current card.
- Do not implement future WG requirements. Finish and verify the current WG first, then ask whether to continue.
- Before any file edit, form the card's handoff card internally and obey its allowed files, forbidden changes, and verification items.
Student-Facing Choice
For WG-13 through WG-21, ask one concise choice:
下一題是 WG-XX:<title>。
你想怎麼進行?
1. 直接實作:老師已講完,我依這題規格改 main.py 並驗收。
2. 先引導:我先用幾個問題幫你整理規格,再實作。For WG-22, use the WG-22 card. It must start with contract alignment rather than the normal direct/guided choice.
WG-13: 工具呼叫與 ReAct 迴圈
Goal
Add the minimal LangChain tool-calling loop so the agent can call tools instead of answering everything directly.
Prerequisite
Project-root main.py has WG-12 style system/history separation.
Allowed Files
main.py
Forbidden
- Do not add file or shell tools yet; those belong to WG-14.
- Do not add JSONL persistence; that belongs to WG-15.
- Do not create
agent_core.py; that belongs to WG-22. - Do not overwrite the student's
nickor persona wording.
Required Changes
- Add
get_identity()by upgrading the WG-12 identity prompt: - Keep the student's existing
nick/ display name. - Replace or expand the old short
system_textso it includes only the WG-13 identity text: 你是課堂程式助教,並請使用繁體中文。【解題方式】write_fileexecuv run python【依賴管理】uv add <套件名>不要用 pip install- Return
f"{system_text}\n\n【本場次顯示名稱】{nick}". - Add
get_runtime_environment()as a separate runtime section: - Keep environment and platform restrictions out of
get_identity(). - Include
【執行環境】. - Detect Windows vs Unix-like at runtime instead of hard-coding the operating system.
- On Windows, include
Windows, the detected shell name when available,【平台限制】,禁止使用 heredoc, andpython - <<'PY'. - Tell the agent to use
write_fileto create a.pyscript, then runuv run python <script.py>for multi-line Python. - Add
add_numbersas a LangChain@tool. - Add
TOOLS = [add_numbers]for WG-13; WG-14 will extend this list with file/shell tools. - Bind tools to the chat model.
- Add
_stream_model_response()or equivalent streaming accumulator. - Add
run_react_turn()with the ReAct loop: - send system + history + current user message;
- stream assistant text;
- execute requested tools;
- append
ToolMessage; - repeat until no tool calls remain.
Preserve
- Student display name / nick in
get_identity(). - The WG-12 identity intent, but not the old one-line
system_textif it lacks【解題方式】and【依賴管理】. - Existing import style unless a new import is required.
Verification
main.pyparses successfully.get_identity,get_runtime_environment,add_numbers,TOOLS,_stream_model_response, andrun_react_turnexist.get_identity()includes【解題方式】,write_file,exec,uv run python,【依賴管理】,uv add, and不要用 pip install, but does not include environment-specific restrictions.get_runtime_environment()includes【執行環境】; on Windows it includesWindows,【平台限制】,禁止使用 heredoc, andpython - <<'PY'.- Pure arithmetic should use
add_numbersinstead of mental math.
Return To Router
After WG-13 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-13 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-13 direct-or-guided
allowed_files: main.py
source: starter_main_wg21.py WG-13 blocks, merged into current main.py
preserve: student nick/display name and existing WG-12 structure
must_include: get_identity, get_runtime_environment, TOOLS = [add_numbers], 解題方式, write_file, exec, uv run python, 依賴管理, uv add, no pip install, runtime 執行環境, Windows runtime heredoc restriction
forbidden: file/shell tools, JSONL, token budget, agent_core.py
verify: required WG-13 symbols exist including TOOLS; get_identity contains standard WG-13 identity text; get_runtime_environment contains platform section; main.py parses
after_verify: ask "WG-13 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-14: Workspace File and Shell Tools
Goal
Give the agent safe workspace tools for reading, writing, editing, listing files, and running commands.
Prerequisite
WG-13 is complete: run_react_turn, TOOLS, and tool binding patterns are available or can be extended.
Allowed Files
main.py
Forbidden
- Do not add JSONL persistence.
- Do not add memory consolidation or skills.
- Do not create
agent_core.py. - Do not loosen workspace path safety.
Required Changes
- Add
WORKSPACE = Path.cwd().resolve()or preserve the student's equivalent. - Add
resolve_workspace_path(). - Add tools:
read_filewrite_fileedit_filelist_direxec/exec_workspace- Add an
exec_workspaceguard for Windows shell compatibility: - reject commands containing heredoc syntax such as
<<orpython - <<'PY'when running on Windows; - return a clear error telling the agent to use
write_fileto create a.pyscript, then runuv run python <script.py>. - Decode captured process output with a UTF-8 / system encoding / CP950 fallback so Windows shell errors do not become mojibake.
- Add or update
TOOLSso all WG-13 and WG-14 tools are included. - Add
_run_bound_tool()if missing. - Ensure
run_react_turn()can execute tool calls and returnToolMessageresults.
Preserve
- Existing
get_identity()persona and nick. - Existing
add_numbersbehavior. - Student workspace constants if already safe.
Verification
main.pyparses successfully.resolve_workspace_path, file tools,exec_workspace,TOOLS, and_run_bound_toolexist.- Path resolution rejects attempts outside the workspace.
- On Windows,
exec_workspace("python - <<'PY'")returns a clear heredoc error instead of passing the command to the shell. - Windows shell output decoding uses fallback encodings to avoid mojibake where possible.
Return To Router
After WG-14 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-14 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-14 direct-or-guided
allowed_files: main.py
source: starter_main_wg21.py WG-14 blocks, merged into current main.py
preserve: nick, WG-13 ReAct loop, safe existing workspace paths
forbidden: JSONL, memory, skills, agent_core.py
verify: workspace tools exist; path safety preserved; heredoc guard exists; output decode fallback exists; main.py parses
after_verify: ask "WG-14 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-15: Write Conversation Turns to JSONL
Goal
Persist each completed conversation turn to a JSONL session file without writing SystemMessage.
Prerequisite
WG-14 is complete. The agent has a working ReAct loop and tools.
Allowed Files
main.py
Forbidden
- Do not load old sessions yet; that belongs to WG-16.
- Do not add token trimming or memory consolidation.
- Do not create
agent_core.py. - Do not persist system prompt content.
Required Changes
- Add metadata helpers such as
_default_metadata(). - Add serialization helpers:
_serialize_tool_calls_message_to_jsonl_line- Add
save_session_jsonl(). - Update
run_react_turn()so it returnsfinal_textplus the fullturn_messageslist, matching starter behavior: - assistant messages;
- tool messages;
- the history user placeholder / current user message as appropriate.
- After each completed turn, save:
- first-line metadata if needed;
- user message;
- assistant/tool messages from the turn.
- Extend in-memory
historywithturn_messagesafter each turn. - Keep system prompt generated at runtime, not written into JSONL.
Preserve
- Existing tools and ReAct loop behavior.
- Student nick/persona.
- Existing session file name if the student already chose one safely.
Verification
save_session_jsonland_message_to_jsonl_lineexist.run_react_turn()returnsfinal_text, turn_messagesor an equivalent pair that preserves assistant/tool messages.- Running a turn writes a JSONL file.
- The JSONL file does not include serialized
SystemMessage.
Return To Router
After WG-15 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-15 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-15 direct-or-guided
allowed_files: main.py
source: starter_main_wg21.py WG-15 JSONL write helpers
preserve: nick, tools, ReAct loop, session filename if present
forbidden: load_session_jsonl, token budget, memory, agent_core.py
verify: JSONL write helpers exist; turn_messages are saved; system messages are not persisted
after_verify: ask "WG-15 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-16: Load Session JSONL on Startup
Goal
Restore conversation history from the JSONL session file when the CLI starts.
Prerequisite
WG-15 is complete. JSONL writing exists and excludes system messages.
Allowed Files
main.py
Forbidden
- Do not add token trimming.
- Do not add memory consolidation.
- Do not create
agent_core.py. - Do not crash on malformed JSONL rows.
Required Changes
- Add
_row_to_message()or equivalent parser. - Add
load_session_jsonl(path). - On startup, load persisted history and metadata if the file exists.
- Skip malformed rows with a warning rather than crashing.
- Continue writing new turns via WG-15 behavior.
Preserve
- Student nick/persona.
- Existing JSONL schema and session file path.
- Existing ReAct/tool behavior.
Verification
load_session_jsonland_row_to_messageexist.- Starting with no session file begins with empty history.
- Starting with an existing file restores history.
- Malformed rows do not crash the program.
Return To Router
After WG-16 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-16 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-16 direct-or-guided
allowed_files: main.py
source: starter_main_wg21.py WG-16 load helpers
preserve: nick, JSONL filename/schema, WG-15 save behavior
forbidden: token budget, memory, skills, agent_core.py
verify: load_session_jsonl exists; cold start restores history safely
after_verify: ask "WG-16 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-17: Token Budget and Consolidation Boundary
Goal
Estimate message cost with character length and decide which old conversation chunk can be trimmed when the prompt is too large.
Prerequisite
WG-16 is complete. The program can load and save session JSONL.
Allowed Files
main.py
Forbidden
- Do not perform long-term memory consolidation yet; that belongs to WG-19.
- Do not rewrite JSONL schema.
- Do not create
agent_core.py.
Required Changes
- Add
get_token_budget(). - Add
estimate_message_tokens(). - Add
message_cost(). - Add
pick_consolidation_boundary(). - Track
last_consolidatedor equivalent boundary state. - Use the budget decision to identify old messages that can be excluded from
past.
Preserve
- Existing JSONL behavior.
- Student nick/persona.
- Current tool and ReAct behavior.
Verification
- Required WG-17 functions exist.
- Boundary selection never cuts through invalid tool-call pairs.
- When under budget, history is sent normally.
- When over budget, old eligible messages can be excluded from the current model input.
Return To Router
After WG-17 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-17 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-17 direct-or-guided
allowed_files: main.py
source: starter_main_wg21.py WG-17 budget helpers
preserve: nick, JSONL behavior, ReAct/tool flow
forbidden: memory consolidation, skills, image handling, agent_core.py
verify: budget helpers exist; boundary logic is safe around tool messages
after_verify: ask "WG-17 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-18: Messages Sent to the Model
Goal
Repair and centralize the transcript sent to the model so loaded history, trimmed past messages, and current-turn messages are assembled consistently.
Prerequisite
WG-17 is complete. Budget helpers and boundary tracking exist.
Allowed Files
main.py
Forbidden
- Do not add long-term memory consolidation yet.
- Do not add image handling yet.
- Do not create
agent_core.py.
Required Changes
- Add
messages_for_model()or equivalent central adapter. - Add
_known_tool_call_ids()or equivalent helper to identify valid tool call IDs before each position. - Ensure tool-call/tool-result pairing remains valid:
- remove orphan
ToolMessageentries whosetool_call_idhas no prior assistant tool call; - preserve matching assistant tool calls and tool results;
- do not mutate the original
history/ JSONL-bound list in place. - Ensure
run_react_turn()uses the central adapter when sending messages. - Preserve current user turn plus selected past context.
Preserve
- Existing ReAct loop.
- Existing JSONL load/save.
- Student nick/persona.
Verification
messages_for_modelexists._known_tool_call_idsor equivalent tool-call ID tracking exists.- Model input excludes invalid tool result leftovers.
- The adapter returns a new list or safe copy; it does not corrupt the persisted history.
- ReAct tool calls still work after the adapter is introduced.
Return To Router
After WG-18 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-18 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-18 direct-or-guided
allowed_files: main.py
source: starter_main_wg21.py WG-18 model-message adapter
preserve: nick, JSONL, budget helpers, ReAct loop
forbidden: memory consolidation, skills, image handling, agent_core.py
verify: messages_for_model and tool-call ID repair exist; adapter does not mutate JSONL history
after_verify: ask "WG-18 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-19: Long-Term Memory Consolidation
Goal
When the conversation exceeds the budget, consolidate old conversation chunks into long-term memory files and read that memory back into the system prompt.
Prerequisite
WG-18 is complete. Model-message assembly and budget boundary logic exist.
Allowed Files
main.py- Project assets may be copied if missing:
prompts/memory_merge.mdtemplates/memory/MEMORY.md
Forbidden
- Do not overwrite existing
prompts/ortemplates/files. - Do not add SkillsLoader yet.
- Do not create
agent_core.py.
Required Changes
- Add memory path helpers and file helpers:
read_memory_mdwrite_memory_mdappend_history_logload_memory_merge_promptis_default_memory_template- Add consolidation helpers such as
_consolidate_pack. - Add
memory_block_for_system(). - Add
ensure_budget_before_react()and call it before each ReAct turn. - Update
last_consolidatedafter successful consolidation. - Update
build_system_prompt()so each turn readsmemory_block_for_system()and appends the## Long-term Memoryblock when present.
Preserve
- Existing budget and
messages_for_modelbehavior. - Student nick/persona.
- Existing project assets if present.
Verification
ensure_budget_before_react,load_memory_merge_prompt, andread_memory_mdexist.- Missing
prompts/ortemplates/are copied fromproject_assetsonly if absent. build_system_prompt()includesmemory_block_for_system()output when memory exists and is not the default template.- Under budget, no consolidation is required.
- Over budget, consolidation can update memory and preserve a valid current turn.
Return To Router
After WG-19 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-19 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-19 direct-or-guided
allowed_files: main.py; missing project_assets targets only
source: starter_main_wg21.py WG-19 memory helpers and project_assets
preserve: nick, existing prompts/templates, budget logic
forbidden: overwrite project assets, skills, image handling, agent_core.py
verify: memory helpers exist; ensure_budget_before_react is called before ReAct; build_system_prompt reads memory_block_for_system
after_verify: ask "WG-19 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-20: SkillsLoader and System Prompt Injection
Goal
Load local skill cards and inject an active skills summary into the system prompt.
Prerequisite
WG-19 is complete. Long-term memory can be read and injected.
Allowed Files
main.py
Forbidden
- Do not add image handling.
- Do not create
agent_core.py. - Do not replace the student's persona or nick.
Required Changes
- Add
SkillEntry. - Add
split_frontmatter(). - Add
SkillsLoader. - Add
build_skills_summary(). - Add
SKILLS_LOADER = SkillsLoader(WORKSPACE)or equivalent. - Update
build_system_prompt()so it combines: get_identity();- long-term memory block;
- active skill bodies under
# Active Skills; - available skills summary from
build_skills_summary().
Preserve
- Student nick/persona from
get_identity(). - Existing long-term memory injection.
- Existing tools and ReAct loop.
Verification
SkillsLoader,SKILLS_LOADER, andbuild_system_promptexist.- System prompt still includes the student's identity.
build_system_prompt()actually includes active skills and available skills summary when skills are present.- Missing or empty skills directories should return an empty list/summary instead of crashing.
- Missing skills directory should not crash the agent.
Return To Router
After WG-20 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-20 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-20 direct-or-guided
allowed_files: main.py
source: starter_main_wg21.py WG-20 SkillsLoader and system prompt blocks
preserve: nick, persona text, memory block, existing tools
forbidden: image handling, agent_core.py, replacing get_identity wholesale
verify: SkillsLoader loads safely; build_system_prompt includes identity + memory + active skills + skills summary
after_verify: ask "WG-20 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-21: Image Input and JSONL Image Path
Goal
Support one image attachment per current user turn, store only its relative image_path in JSONL, and rebuild loaded history without re-sending old images.
Prerequisite
WG-20 is complete. Skills and memory are integrated into the system prompt.
Allowed Files
main.py
Forbidden
- Do not create
agent_core.py. - Do not store base64 image data in JSONL.
- Do not re-send historical images to the model.
- Do not accept absolute image paths.
Required Changes
- Add
PROJECT_ROOT. - Add image helpers:
guess_media_typeimage_bytes_to_data_urlresolve_project_image_path- Add history/user helpers:
history_human_placeholderhuman_fields_for_jsonlload_user_row_to_history_humanbuild_human_message_for_current_turn- Add model-send helpers for images:
_human_text_length_human_to_text_only_for_model_keep_image_only_on_current_human- Update JSONL serialization/loading so user rows can store
image_path. - Update CLI
main()with/imageandpending_imagebehavior.
Preserve
- Student nick/persona.
- Existing JSONL schema fields; only extend as needed.
- Existing tools, memory, and skills behavior.
Verification
resolve_project_image_pathandbuild_human_message_for_current_turnexist./image relative/path.pngselects an image for the next text turn./image path questionsends image + text in one turn.- JSONL stores relative
image_path, not base64. - Loaded history uses placeholders/text only; only current turn includes image content.
Return To Router
After WG-21 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-21 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-21 direct-or-guided
allowed_files: main.py
source: starter_main_wg21.py WG-21 image helpers and CLI parsing
preserve: nick, JSONL history, memory, skills, session path
forbidden: agent_core.py, absolute paths, base64 in JSONL, historical image re-send
verify: image helpers exist; /image CLI works; JSONL stores image_path only
after_verify: ask "WG-21 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next cardWG-22: Split Agent Core from CLI
Goal
Move WG-12~21 execution logic from single-file main.py into agent_core.py, expose class Agent, and leave main.py as a thin CLI shell.
Prerequisite
WG-21 is complete in project-root main.py.
Allowed Files
agent_core.pymain.py
Forbidden
- Do not offer the normal WG-13~21 direct/guided choice.
- Do not implement before contract alignment and six-column confirmation.
- Do not copy
reference_agent_core.pyorreference_main.pywholesale into the answer files. - Do not rewrite ReAct/JSONL/memory logic from scratch.
- Do not leave
input()inagent_core.py. - Do not leave
run_react_turn,save_session_jsonl, orensure_budget_before_reactin thinmain.py.
Student-Facing Flow
1. Show WG-22 progress and context: completed single-file agent, current single-file pain, goal of core/CLI split. 2. Ask one 2a question about expected user-facing behavior after the split. 3. Continue 2b~2d′ one question at a time. 4. Convert confirmed answers into the six-column contract. 5. Ask once whether to start implementation. 6. Implement only after the student explicitly says "開始實作" or equivalent.
Required API
Agent.from_env()- calls
load_dotenv(); - checks
OPENAI_API_KEY; - accepts optional
session_path; - otherwise reads
os.getenv("SESSION_JSONL_PATH", "session.jsonl"); - loads JSONL history;
- creates
ChatOpenAI(model="gpt-5.4-mini", temperature=0.2); - binds
TOOLS; - restores
last_consolidated. Agent.chat(user_text, *, image_path=None, on_token=None) -> str- rejects absolute/out-of-project
image_paththrough the existing WG-21 path resolver behavior; - runs memory budget/consolidation;
- runs ReAct;
- saves JSONL;
- extends in-memory history;
- returns final assistant text;
- supports WG-21 image path;
- supports optional
on_tokencallback: assistant text tokens go to callback and are not printed a second time when callback is provided.
Split Steps
| Step | Work | Verification before next step |
|---|---|---|
| 1 | Create agent_core.py and migrate WG-13~16 helpers from current main.py. | python -c "import agent_core" succeeds; WG-13~16 symbols exist in agent_core.py. |
| 2 | Migrate WG-17~21 helpers, including image helpers and memory/skills. | run_react_turn, messages_for_model, ensure_budget_before_react, SkillsLoader, and build_human_message_for_current_turn exist in agent_core.py. |
| 3 | Add class Agent with from_env() and chat(...). | python -c "from agent_core import Agent" succeeds; Agent.from_env and Agent.chat exist with the required API. |
| 4 | Replace main.py with a thin CLI that catches RuntimeError, handles quit commands, parses /image, and calls agent.chat(...). | main.py has no def run_react_turn, def save_session_jsonl, or def ensure_budget_before_react; agent_core.py has no input(. |
| 5 | Run final checklist. | uv run main.py starts; missing key is reported without traceback; behavior remains equivalent to WG-21 starter. |
Preserve
- Student nick/persona and approved path choices.
- Existing session file behavior unless the contract says otherwise.
- CLI user experience from the WG-21 single-file version.
Verification
from agent_core import Agentworks.Agent.from_env()usesload_dotenv,OPENAI_API_KEY,SESSION_JSONL_PATHdefaulting tosession.jsonl,gpt-5.4-mini, andTOOLS.Agent.chat(...)returns final text and supportsimage_pathpluson_token.- With
on_tokenprovided, assistant text tokens are emitted through the callback and not printed a second time. agent_core.pyhas noinput(.main.pyhas nodef run_react_turn,def save_session_jsonl, ordef ensure_budget_before_react.main.pycallsagent.chat(...)for each turn.uv run main.pystarts; missing key is reported without traceback.- Tool calls, JSONL load/save, memory consolidation, skills, and
/imagebehavior remain equivalent to WG-21.
Return To Router
After WG-22 passes verification:
1. Report the completed WG briefly. 2. Ask exactly: WG-22 已完成。要繼續下一題嗎? 3. If the student says yes, return to references/wg_milestone_checklist.md, recompute next_wg, then read only the next routed card. 4. Do not preload future cards. 5. Do not continue automatically without explicit confirmation.
Handoff Card
mode: WG-22 contract-first split
allowed_files: agent_core.py, main.py
source: current WG-21 main.py / starter_main_wg21.py behavior as logic source; reference_agent_core.py/reference_main.py as structural checks only
preserve: nick, persona, session path choices, CLI behavior
forbidden: direct implementation before contract, wholesale reference copy, rewriting core logic, input() in core, alternate model name
verify: Agent API exists; gpt-5.4-mini; SESSION_JSONL_PATH default session.jsonl; on_token behavior; thin main; no core loop in main; uv run main.py smoke test
after_verify: ask "WG-22 已完成。要繼續下一題嗎?"; if yes, rescan and route; read only one next card實作紀錄模板(每題一則,寫入 md)
與 peas-example-coach 陪練紀錄(思考格)互補;本檔記錄「做了什麼、為什麼這樣做」。
---
Challenge WG-XX:<Challenge 標題>
- 題意摘要:用一句話描述這題要做什麼(由 agent 根據當前 challenge card 填寫,不直接複製規格原文)。
- 實作方式:學生採取的做法概述 — 新增了哪些函式/區塊、改了哪幾行、用了什麼資料結構。若做法與示範檔不同但符合驗收,在此註明「替代方案」。
- 遇到的問題:實作過程中卡關的地方、錯誤訊息、除錯過程(學生自述或 agent 觀察)。若一路順利可寫「無明顯卡關」。
- 設計決策 / 理解:學生對驗收條件中「能說明…」題目的回答摘要(由學生口述,agent 整理)。
- 驗收結果:
- [ ] <驗收條件 1 精簡描述> — ✅ 通過 / ❌ 未通過(附註)
- [ ] <驗收條件 2 精簡描述> — ✅ 通過 / ❌ 未通過(附註)
- Agent 備註:值得注意的學習亮點、仍可深入的方向(一兩句即可)。
PEAS 開場品牌畫面(Workshop Bridge Coach)
供 peas-workshop-advanced-coach 在新工作階段第一則學生可見訊息頂端使用。
輸出順序:
1. 一行純文字字標:PEAS · Workshop 進階教練 2. 空一行 3. 下方「對話用版面」作為單一 text fenced code block 4. 同一則訊息最後加一個準備確認問句
首則訊息不得包含進度列、題目、掃描結果或實作內容。
對話用版面
┌─────────────────────────────────────────────────────────────┬──────────────────────────┐
│ │ │
│ /| PEAS Workshop Bridge · WG-13~22 │Tips for getting started │
│ / | Session: 本輪教練剛開始 │先掃描目前進度, │
│ / | Mode: Scan → Route → Card → Verify │每次只處理一題。 │
│ / | │ │
│ \ | │Recent activity │
│ \ | │等待你說準備好了 │
│ \| │ │
│ │ │
│ ~/your/project/root/path… │ │
│ │ │
└─────────────────────────────────────────────────────────────┴──────────────────────────┘缺檔 fallback
若本檔無法讀取,仍輸出簡化版:
PEAS · Workshop 進階教練
┌──────────────────────────────┐
│ PEAS Workshop Bridge │
│ Scan → Route → Card │
└──────────────────────────────┘你是長期記憶整併助手。將「既有 MEMORY.md」與「待整併對話 chunk」合併成下一輪仍需要的狀態。
MEMORY.md 是決策與狀態備忘,不是對話逐字稿、不是 tool 輸出備份、不能取代 session.jsonl。
檔案結構(固定,不可刪改標題)
memory_update 必須是完整 markdown,且固定包含以下標題(各節用 bullet;可空 bullet,但標題不可省略):
Long-term Memory
User Information
Preferences
Project Context
Important Notes
內容可寫繁中;章節標題維持上述英文。
什麼值得記(優先高 → 低)
1. 使用者更正與穩定偏好 2. 已驗證可行的解法或做法 3. 已確認的決策與規格 4. 計畫、截止、重要事件
章節對應
- User Information:使用者身份、穩定事實
- Preferences:溝通風格、工具偏好、回覆方式
- Project Context:任務目標、進度、技術決策、專案錨點(檔名等)
- Important Notes:其他 durable 備忘
不應寫入 memory_update
- 每輪問答原文、問候、一次性測試
- tool 成功/失敗過程、retry 細節
- 版本史逐條堆疊(A 後來改 B 只留目前有效一條)
- Skill 完整流程正文(只寫「見 skill: xxx」)
- 除錯過程、語法錯誤、一次性統計
整併原則
- 合併 CURRENT MEMORY 與 chunk;刪除過期、重複、已被取代的 bullet
- chunk 與 MEMORY 衝突時,以 chunk(使用者更正)為準
- 禁止逐句貼上 chunk
- history_entry 僅供 HISTORY.md 一行 log,不要把 HISTORY 內容抄進 MEMORY
輸出格式
僅回傳 JSON 物件,不要 markdown fence,不要其他文字。只能有兩個鍵:
- "history_entry":繁中單行,摘要本次整併主題;若無 noteworthy 可寫 "(nothing)"
- "memory_update":完整 markdown,將覆寫 memory/MEMORY.md(非 append)
Long-term Memory
This file stores important information that should persist across sessions.
User Information
(Important facts about the user)
Preferences
(User preferences learned over time)
Project Context
(Information about ongoing projects)
Important Notes
(Things to remember)
---
This file is automatically updated by agent when important information should be remembered.
"""
Agent Workshop 標準核心(reference_agent_core.py)— peas-workshop-advanced-coach 內唯讀對照。
WG-22 拆檔後標準答案(同專案根 `agent_core.py`);教練驗收與 Spec 以本檔 + `reference_main.py` 為準。
學生實作請改專案根 **`agent_core.py`** + **`main.py`**;勿直接修改本 skill references。
公開 API:`Agent.from_env()`、`Agent.chat(user_text, *, image_path=..., on_token=...)`
"""
from __future__ import annotations
import base64
import copy
import json
import locale
import os
import re
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
message_chunk_to_message,
)
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
# ---------------------------------------------------------------------------
# WG-12:人設與 system/history 分離(main 內 system_text;不寫 SystemMessage 進 JSONL)
# ---------------------------------------------------------------------------
# 本區無獨立函式:每輪以 build_system_prompt() 產生 system_text,run_react_turn 內組 SystemMessage。
# build_system_prompt() 完整實作見 WG-20(WG-19 起併 memory_block_for_system)。
# ---------------------------------------------------------------------------
# WG-13:get_identity、add_numbers、串流輔助(run_react_turn 見 WG-18 之後)
# ---------------------------------------------------------------------------
def get_identity() -> str:
"""WG-13 自 build_system_prompt 抽出;含【解題方式】【依賴管理】。"""
system_text = (
"你是課堂程式助教,並請使用繁體中文。\n\n"
"【解題方式】可重複驗證的任務,必須先用 write_file 寫成 .py 腳本,"
"再用 exec 執行(例如 uv run python 相對路徑);"
"避免只在對話中口算或貼無法重跑的一次性指令。\n\n"
"【依賴管理】本專案用 uv 管理套件;新增 Python 依賴請在專案根 exec "
"uv add <套件名>,不要用 pip install。"
)
nick = "法鬥超人"
return f"{system_text}\n\n【本場次顯示名稱】{nick}"
def _detect_shell_name() -> str:
shell = os.environ.get("SHELL") or os.environ.get("COMSPEC") or ""
shell_name = Path(shell).name if shell else ""
if os.name == "nt":
if "powershell" in shell_name.lower() or os.environ.get("PSModulePath"):
return "PowerShell"
return shell_name or "Windows shell"
return shell_name or "POSIX shell"
def get_runtime_environment() -> str:
"""回傳目前工具執行環境與平台限制;不要混入人物設定。"""
shell_name = _detect_shell_name()
if os.name == "nt":
return (
f"【執行環境】目前工具執行在 Windows / {shell_name} 環境。\n\n"
"【平台限制】Windows shell 不支援 heredoc,禁止使用 "
"`python - <<'PY'`、`python - <<\"PY\"` 或任何包含 `<<` 的多行 shell 寫法。"
"需要執行多行 Python 時,必須先用 write_file 寫入 .py 檔,再用 "
"uv run python <script.py> 執行。"
)
return (
f"【執行環境】目前工具執行在 Unix-like / {shell_name} 環境。\n\n"
"【平台限制】可重複驗證的多行 Python 仍應先用 write_file 寫入 .py 檔,"
"再用 uv run python <script.py> 執行。"
)
@tool
def add_numbers(a: float, b: float) -> float:
"""兩個數字相加並回傳和。純算術必須呼叫此工具,不可心算後直接回答。"""
return float(a) + float(b)
def _stream_model_response(
llm_tools: ChatOpenAI,
messages: list[BaseMessage],
on_token: Callable[[str], None] | None = None,
) -> AIMessage:
"""串流累積為 AIMessage;僅印模型文字,工具執行由呼叫端處理。"""
acc: AIMessageChunk | None = None
for chunk in llm_tools.stream(messages):
acc = chunk if acc is None else acc + chunk
content = chunk.content
if isinstance(content, str) and content:
if on_token is not None:
on_token(content)
else:
print(content, end="", flush=True)
if acc is None:
raise RuntimeError("模型串流未回傳任何 chunk")
return message_chunk_to_message(acc)
# ---------------------------------------------------------------------------
# WG-14:workspace 檔案/shell `@tool`(追加至 WG-13 之 TOOLS)
# ---------------------------------------------------------------------------
WORKSPACE = Path.cwd().resolve()
def resolve_workspace_path(path: str) -> Path:
raw = Path(path)
if raw.is_absolute():
raise PermissionError("absolute paths are not allowed")
target = (WORKSPACE / path).resolve()
try:
target.relative_to(WORKSPACE)
except ValueError as e:
raise PermissionError(f"path is outside workspace: {path}") from e
return target
@tool("read_file")
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
"""讀取 workspace 內 UTF-8 文字檔,回傳帶行號內容。"""
try:
target = resolve_workspace_path(path)
if not target.is_file():
return f"Error: not a file: {path}"
lines = target.read_text(encoding="utf-8").splitlines()
start = max(offset - 1, 0)
end = min(start + limit, len(lines))
return "\n".join(f"{i + 1}| {line}" for i, line in enumerate(lines[start:end], start))
except Exception as e:
return f"Error: {e}"
@tool("write_file")
def write_file(path: str, content: str) -> str:
"""整檔覆寫寫入 UTF-8 文字檔(必要時建立父資料夾)。"""
try:
target = resolve_workspace_path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"wrote {len(content)} characters to {path}"
except Exception as e:
return f"Error: {e}"
@tool("edit_file")
def edit_file(path: str, old_text: str, new_text: str, replace_all: bool = False) -> str:
"""在既有檔案中把 old_text 換成 new_text(預設僅單次替換)。"""
try:
target = resolve_workspace_path(path)
text = target.read_text(encoding="utf-8")
count = text.count(old_text)
if count == 0:
return "Error: old_text not found"
if count > 1 and not replace_all:
return "Error: old_text appears multiple times"
target.write_text(
text.replace(old_text, new_text, -1 if replace_all else 1),
encoding="utf-8",
)
return f"edited {path}"
except Exception as e:
return f"Error: {e}"
@tool("list_dir")
def list_dir(path: str, recursive: bool = False, max_entries: int = 200) -> str:
"""列出 workspace 內資料夾內容。"""
try:
root = resolve_workspace_path(path)
if not root.is_dir():
return f"Error: not a directory: {path}"
iterator = root.rglob("*") if recursive else root.iterdir()
entries = [str(item.relative_to(WORKSPACE)) for item in iterator][:max_entries]
return "\n".join(entries) if entries else "(empty)"
except Exception as e:
return f"Error: {e}"
@tool("exec")
def exec_workspace(command: str, timeout: int = 30) -> str:
"""在 workspace 目錄下執行 shell 指令(已阻擋常見危險片段)。"""
blocked = ("rm -rf", "del /f", "rmdir /s", "format", "shutdown")
lowered = command.lower()
if any(part in lowered for part in blocked):
return "Error: blocked dangerous command (safety limit)"
if os.name == "nt" and "<<" in command:
return (
"Error: heredoc syntax is disabled in this Windows runtime. "
"Use write_file to create a .py script, then run it with "
"uv run python <script.py>."
)
child_env = os.environ.copy()
child_env.setdefault("PYTHONUTF8", "1")
child_env.setdefault("PYTHONIOENCODING", "utf-8")
run_kw: dict[str, Any] = {
"cwd": str(WORKSPACE),
"shell": True,
"capture_output": True,
"timeout": timeout,
"env": child_env,
}
if os.name == "nt":
run_kw["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
try:
result = subprocess.run(command, **run_kw)
stdout = _decode_process_output(result.stdout or b"")
stderr = _decode_process_output(result.stderr or b"")
output = (stdout + stderr).strip()
cap = 4000
if len(output) > cap:
output = output[:cap] + "\n\n[truncated]"
if not output:
output = "(no stdout or stderr; command finished with no captured output)"
return f"exit_code={result.returncode}\n{output}"
except Exception as e:
return f"Error: {e}"
def _decode_process_output(data: bytes) -> str:
encodings = ["utf-8", locale.getpreferredencoding(False), "cp950"]
for encoding in dict.fromkeys(encodings):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace")
TOOLS = [
add_numbers,
read_file,
write_file,
edit_file,
list_dir,
exec_workspace,
]
_TOOL_BY_NAME: dict[str, Any] = {t.name: t for t in TOOLS}
def _run_bound_tool(name: str, args: dict[str, Any]) -> str:
tool_obj = _TOOL_BY_NAME.get(name)
if tool_obj is None:
return f"Error: unknown tool {name!r}"
try:
out = tool_obj.invoke(dict(args or {}))
return str(out)
except Exception as e:
return f"Error running tool {name}: {e}"
# ---------------------------------------------------------------------------
# WG-15:JSONL 寫入(每輪 extend 後 save;第一行 metadata;不寫 SystemMessage)
# ---------------------------------------------------------------------------
def _default_metadata(created_at: str | None = None) -> dict[str, Any]:
"""建立第一行 metadata 物件(與 session.jsonl.example 欄位對齊)。"""
now = datetime.now().isoformat()
return {
"_type": "metadata",
"key": "session",
"created_at": created_at or now,
"updated_at": now,
"metadata": {},
"last_consolidated": 0,
}
def _serialize_tool_calls(tc: Any) -> list[dict[str, Any]]:
if not tc:
return []
out: list[dict[str, Any]] = []
for item in tc:
if isinstance(item, dict):
out.append(
{
"name": item.get("name", ""),
"args": dict(item.get("args") or {}),
"id": str(item.get("id", "")),
}
)
return out
def _message_to_jsonl_line(m: BaseMessage) -> str | None:
ts = datetime.now().isoformat()
if isinstance(m, HumanMessage):
text, image_path, media_type = human_fields_for_jsonl(m)
row: dict[str, Any] = {"role": "user", "content": text, "timestamp": ts}
if image_path:
row["image_path"] = image_path
if media_type:
row["media_type"] = media_type
elif isinstance(m, AIMessage):
row = {"role": "assistant", "content": m.content, "timestamp": ts}
tc = getattr(m, "tool_calls", None)
if tc:
row["tool_calls"] = _serialize_tool_calls(tc)
elif isinstance(m, ToolMessage):
row = {
"role": "tool",
"content": m.content,
"tool_call_id": m.tool_call_id,
"timestamp": ts,
}
tname = getattr(m, "name", None)
if tname:
row["name"] = tname
else:
return None
return json.dumps(row, ensure_ascii=False)
def save_session_jsonl(
path: str,
messages: list[BaseMessage],
existing_meta: dict[str, Any] | None,
last_consolidated: int,
) -> dict[str, Any]:
now = datetime.now().isoformat()
if existing_meta is None:
meta = _default_metadata(created_at=now)
else:
meta = dict(existing_meta)
meta["_type"] = "metadata"
meta["key"] = meta.get("key", "session")
if "created_at" not in meta:
meta["created_at"] = now
meta["updated_at"] = now
meta["last_consolidated"] = last_consolidated
lines: list[str] = [json.dumps(meta, ensure_ascii=False)]
for m in messages:
line = _message_to_jsonl_line(m)
if line is not None:
lines.append(line)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
if lines:
f.write("\n")
return meta
# ---------------------------------------------------------------------------
# WG-16:JSONL 載入(啟動還原 history/session_meta;壞行略過)
# ---------------------------------------------------------------------------
def _row_to_message(obj: dict[str, Any]) -> BaseMessage | None:
role = obj.get("role")
if role == "user":
return load_user_row_to_history_human(obj)
if role == "assistant":
content = str(obj.get("content", ""))
tc = obj.get("tool_calls")
if tc:
return AIMessage(content=content, tool_calls=_serialize_tool_calls(tc))
return AIMessage(content=content)
if role == "tool":
tid = obj.get("tool_call_id") or ""
nm = str(obj.get("name", "") or "").strip() or None
return ToolMessage(
content=str(obj.get("content", "")),
tool_call_id=str(tid),
name=nm,
)
return None
def load_session_jsonl(path: str) -> tuple[list[BaseMessage], dict[str, Any] | None]:
if not os.path.exists(path):
return [], None
messages: list[BaseMessage] = []
meta: dict[str, Any] | None = None
with open(path, encoding="utf-8") as f:
for raw in f:
line = raw.strip()
if not line:
continue
try:
obj: Any = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(obj, dict) and obj.get("_type") == "metadata":
meta = obj
continue
if isinstance(obj, dict):
msg = _row_to_message(obj)
if msg is not None:
messages.append(msg)
return messages, meta
# ---------------------------------------------------------------------------
# WG-21:多模態附圖、JSONL image_path、history 占位、送模剝歷史圖
# ---------------------------------------------------------------------------
PROJECT_ROOT = Path(__file__).resolve().parent
_IMAGE_PLACEHOLDER_RE = re.compile(
r"\n\n\[此回合曾附圖,路徑:([^\]]+)\](?:(media_type=([^)]+)))?\s*$"
)
def guess_media_type(path: Path, fallback: str = "image/png") -> str:
ext = path.suffix.lower()
if ext in (".jpg", ".jpeg"):
return "image/jpeg"
if ext == ".png":
return "image/png"
if ext == ".webp":
return "image/webp"
return fallback
def image_bytes_to_data_url(data: bytes, media_type: str) -> str:
b64 = base64.b64encode(data).decode("ascii")
return f"data:{media_type};base64,{b64}"
def resolve_project_image_path(rel: str) -> Path:
"""WG-21:相對路徑須落在專案根下(防 .. 逃出)。"""
raw = Path(rel)
if raw.is_absolute():
raise PermissionError("absolute image paths are not allowed")
full = (PROJECT_ROOT / rel).resolve()
try:
full.relative_to(PROJECT_ROOT)
except ValueError as e:
raise PermissionError(f"image path is outside project root: {rel}") from e
return full
def parse_history_human_content(content: str) -> tuple[str, str | None, str | None]:
match = _IMAGE_PLACEHOLDER_RE.search(content)
if not match:
return content, None, None
text = content[: match.start()].rstrip()
return text, match.group(1), match.group(2)
def history_human_placeholder(
text: str, image_rel: str | None, media_type: str | None = None
) -> HumanMessage:
"""WG-21:寫入 history/JSONL 前之純字串 user(含附圖占位)。"""
if not image_rel:
return HumanMessage(content=text)
extra = f"[此回合曾附圖,路徑:{image_rel}]"
if media_type:
extra += f"(media_type={media_type})"
body = f"{text}\n\n{extra}" if text else extra
return HumanMessage(content=body)
def human_fields_for_jsonl(m: HumanMessage) -> tuple[str, str | None, str | None]:
"""自 history 占位 HumanMessage 抽出 JSONL 欄位(不得序列化 list content)。"""
if isinstance(m.content, list):
raise ValueError("WG-21:不可將多模態 HumanMessage 直接寫入 JSONL")
content = str(m.content)
text, image_path, media_type = parse_history_human_content(content)
return text, image_path, media_type
def load_user_row_to_history_human(row: dict[str, Any]) -> HumanMessage:
"""WG-21/WG-16:冷啟動載入 user 列;有 image_path 亦只還原占位,不讀圖。"""
text = str(row.get("content", ""))
rel = row.get("image_path")
if not rel:
return HumanMessage(content=text)
mt = row.get("media_type")
return history_human_placeholder(text, str(rel), str(mt) if mt else None)
def build_human_message_for_current_turn(
text: str, image_rel: str | None
) -> HumanMessage:
"""WG-21:僅本輪送模可組多模態;此時才 open(rb)。"""
if not image_rel:
return HumanMessage(content=text)
try:
full = resolve_project_image_path(image_rel)
except PermissionError as e:
print(f"[warn] rejected image path: {e}")
return HumanMessage(content=text)
if not full.is_file():
print(f"[warn] missing image for current turn: {image_rel}")
return HumanMessage(content=text)
media_type = guess_media_type(full)
with open(full, "rb") as f:
data = f.read()
url = image_bytes_to_data_url(data, media_type)
blocks: list[dict[str, Any]] = []
if text:
blocks.append({"type": "text", "text": text})
blocks.append({"type": "image_url", "image_url": {"url": url}})
return HumanMessage(content=blocks)
def _human_text_length(message: HumanMessage) -> int:
content = message.content
if isinstance(content, str):
return len(content)
if isinstance(content, list):
total = 0
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
total += len(str(block.get("text", "")))
return total
return len(str(content))
def _human_to_text_only_for_model(m: HumanMessage) -> HumanMessage:
"""WG-21:送模前剝除 history 內 image_url 區塊。"""
content = m.content
if isinstance(content, str):
return copy.deepcopy(m)
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text", "")))
body = (
"\n".join(p for p in parts if p).strip()
or "[(無文字)此則曾含圖,已於送模層剝除圖區塊]"
)
return HumanMessage(content=body + "\n\n[送模層已剝除歷史圖區塊]")
return HumanMessage(content=str(content))
def _last_human_index(messages: list[BaseMessage]) -> int | None:
for i in range(len(messages) - 1, -1, -1):
if isinstance(messages[i], HumanMessage):
return i
return None
def _keep_image_only_on_current_human(messages: list[BaseMessage]) -> list[BaseMessage]:
"""WG-21:送模副本中,僅本輪(最後一則)HumanMessage 可保留 image;其餘剝為純文字。"""
last_human = _last_human_index(messages)
out: list[BaseMessage] = []
for i, msg in enumerate(messages):
mm = copy.deepcopy(msg)
if isinstance(mm, HumanMessage) and last_human is not None and i != last_human:
mm = _human_to_text_only_for_model(mm)
out.append(mm)
return out
# ---------------------------------------------------------------------------
# WG-17:字元預算與送模裁切(history 全量保留,past 為送模切片)
# ---------------------------------------------------------------------------
def get_token_budget() -> int:
raw = os.getenv("TOKEN_BUDGET", "100000")
try:
n = int(raw)
return n if n > 0 else 100000
except ValueError:
return 100000
def estimate_message_tokens(message: BaseMessage) -> int:
if isinstance(message, HumanMessage):
return _human_text_length(message)
content = message.content
return len(content) if isinstance(content, str) else 0
def message_cost(msgs: list[BaseMessage]) -> int:
return sum(estimate_message_tokens(m) for m in msgs)
def pick_consolidation_boundary(
messages: list[BaseMessage],
last_consolidated: int,
tokens_to_remove: int,
) -> tuple[int, int] | None:
"""自 last_consolidated 掃描,挑「使用者回合開頭」idx,使略過的權重足夠。"""
start = last_consolidated
if start >= len(messages) or tokens_to_remove <= 0:
return None
removed_tokens = 0
last_boundary: tuple[int, int] | None = None
for idx in range(start, len(messages)):
message = messages[idx]
if idx > start and isinstance(message, HumanMessage):
last_boundary = (idx, removed_tokens)
if removed_tokens >= tokens_to_remove:
return last_boundary
removed_tokens += estimate_message_tokens(message)
return last_boundary
# ---------------------------------------------------------------------------
# WG-18:送模 transcript 修復(完整 history 與 messages_for_model 副本分離)
# ---------------------------------------------------------------------------
def _known_tool_call_ids(messages: list[BaseMessage], before_index: int) -> set[str]:
ids: set[str] = set()
for msg in messages[:before_index]:
if not isinstance(msg, AIMessage):
continue
for tc in msg.tool_calls or []:
if isinstance(tc, dict):
tid = tc.get("id")
if tid:
ids.add(str(tid))
return ids
def messages_for_model(messages: list[BaseMessage]) -> list[BaseMessage]:
"""WG-18+WG-21:送模副本(tool 修復;歷史剝圖、本輪可多模態)。
回傳新 list,不就地修改輸入(避免污染將寫入 JSONL 的 history)。
"""
out: list[BaseMessage] = copy.deepcopy(messages)
# A: drop orphan ToolMessage rows
kept: list[BaseMessage] = []
for msg in out:
if isinstance(msg, ToolMessage):
tid = str(msg.tool_call_id or "")
if tid and tid in _known_tool_call_ids(kept, len(kept)):
kept.append(msg)
else:
kept.append(msg)
out = kept
# B: backfill missing ToolMessage after AIMessage tool_calls
unavailable_tool_text = "[Tool result unavailable — call was interrupted or lost]"
i = 0
while i < len(out):
msg = out[i]
if not isinstance(msg, AIMessage):
i += 1
continue
tool_calls = msg.tool_calls or []
if not tool_calls:
i += 1
continue
j = i + 1
responded: set[str] = set()
while j < len(out) and isinstance(out[j], ToolMessage):
responded.add(str(out[j].tool_call_id or ""))
j += 1
insert_at = j
for tc in tool_calls:
if not isinstance(tc, dict):
continue
tid = str(tc.get("id", "") or "")
if not tid or tid in responded:
continue
name = str(tc.get("name", "") or "").strip() or None
out.insert(
insert_at,
ToolMessage(
content=unavailable_tool_text,
tool_call_id=tid,
name=name,
),
)
insert_at += 1
i += 1
return _keep_image_only_on_current_human(out)
# ---------------------------------------------------------------------------
# WG-13(續):run_react_turn(依 WG-14 _TOOL_BY_NAME、WG-18 messages_for_model)
# ---------------------------------------------------------------------------
def run_react_turn(
llm_tools: ChatOpenAI,
system_text: str,
past: list[BaseMessage],
human_message: HumanMessage,
history_human: HumanMessage | None = None,
on_token: Callable[[str], None] | None = None,
) -> tuple[str, list[BaseMessage]]:
"""單輪 ReAct:stream → tool_calls → ToolMessage 迴圈,直到純文字回覆。
WG-17:`past` 為裁切後送模切片;完整 `history` 由 `main()` 另行累積。
WG-18+WG-21:每段 stream 前以 `messages_for_model` 修復 transcript 並剝歷史圖。
WG-21:`human_message` 可為本輪多模態;`history_human` 為寫入 history 之占位版。
"""
messages: list[BaseMessage] = [
SystemMessage(content=system_text),
*past,
human_message,
]
idx_turn_start = 1 + len(past)
while True:
messages = messages_for_model(messages)
response = _stream_model_response(llm_tools, messages, on_token=on_token)
messages.append(response)
print()
if response.tool_calls:
for tc in response.tool_calls:
name = str(tc["name"])
raw_args = dict(tc.get("args") or {})
result = _run_bound_tool(name, raw_args)
print(f"\n[工具 {name}]\n{result}\n", flush=True)
messages.append(
ToolMessage(
content=result,
tool_call_id=str(tc["id"]),
name=name,
)
)
else:
break
turn_messages = messages[idx_turn_start:]
if history_human is not None and turn_messages:
turn_messages = [history_human, *turn_messages[1:]]
final_content = response.content
final_text = (
final_content.strip()
if isinstance(final_content, str)
else str(final_content).strip()
)
return final_text, turn_messages
# ---------------------------------------------------------------------------
# WG-19:長期記憶(memory/MEMORY.md、memory/HISTORY.md、整併 helpers)
# ensure_budget_before_react 見 WG-20 之後(呼叫 build_system_prompt)
# ---------------------------------------------------------------------------
REFERENCE_DIR = Path(__file__).resolve().parent
MEMORY_DIR = REFERENCE_DIR / "memory"
MEMORY_PATH = MEMORY_DIR / "MEMORY.md"
HISTORY_PATH = MEMORY_DIR / "HISTORY.md"
MEMORY_TEMPLATE_PATH = REFERENCE_DIR / "templates" / "memory" / "MEMORY.md"
MEMORY_MERGE_PROMPT_PATH = REFERENCE_DIR / "prompts" / "memory_merge.md"
LONG_TERM_MEMORY_HEADING = "## Long-term Memory"
CONSOLIDATION_MAX_RETRIES = 3
def read_memory_md() -> str:
if not MEMORY_PATH.is_file():
return ""
return MEMORY_PATH.read_text(encoding="utf-8").strip()
def load_memory_merge_prompt() -> str:
return MEMORY_MERGE_PROMPT_PATH.read_text(encoding="utf-8")
def is_default_memory_template(content: str) -> bool:
"""True when MEMORY.md is still the bundled nanobot starter template."""
if not content.strip():
return True
if not MEMORY_TEMPLATE_PATH.is_file():
return False
return content.strip() == MEMORY_TEMPLATE_PATH.read_text(encoding="utf-8").strip()
def memory_block_for_system() -> str:
"""有 MEMORY.md 內文且非預設模板時,回傳 ## Long-term Memory 區塊(全文讀入,不截斷)。"""
body = read_memory_md()
if not body or is_default_memory_template(body):
return ""
return f"{LONG_TERM_MEMORY_HEADING}\n\n{body}"
def append_history_log(line: str) -> None:
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
single = " ".join(line.split())
with open(HISTORY_PATH, "a", encoding="utf-8") as f:
f.write(f"[{ts}] {single}\n")
def write_memory_md(content: str) -> None:
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
MEMORY_PATH.write_text(content, encoding="utf-8")
def _message_plaintext(message: BaseMessage) -> str:
if isinstance(message, HumanMessage):
role = "user"
elif isinstance(message, AIMessage):
role = "assistant"
elif isinstance(message, ToolMessage):
role = "tool"
else:
role = "other"
if isinstance(message, HumanMessage):
content = (
message.content
if isinstance(message.content, str)
else _human_to_text_only_for_model(message).content
)
else:
content = message.content if isinstance(message.content, str) else str(message.content)
extra = ""
if isinstance(message, AIMessage) and message.tool_calls:
names = [
str(tc.get("name", ""))
for tc in message.tool_calls
if isinstance(tc, dict)
]
extra = f" [tool_calls: {', '.join(names)}]"
return f"{role}{extra}: {content}"
def _chunk_to_text(chunk: list[BaseMessage]) -> str:
return "\n".join(_message_plaintext(m) for m in chunk)
def _parse_consolidation_json(text: str) -> dict[str, str] | None:
text = text.strip()
try:
obj = json.loads(text)
if isinstance(obj, dict) and "history_entry" in obj and "memory_update" in obj:
return {
"history_entry": str(obj["history_entry"]),
"memory_update": str(obj["memory_update"]),
}
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
obj = json.loads(text[start : end + 1])
if isinstance(obj, dict) and "history_entry" in obj and "memory_update" in obj:
return {
"history_entry": str(obj["history_entry"]),
"memory_update": str(obj["memory_update"]),
}
except json.JSONDecodeError:
pass
return None
def _invoke_consolidation(
consolidation_llm: ChatOpenAI,
chunk_text: str,
existing_memory: str,
) -> dict[str, str] | None:
consolidation_system = load_memory_merge_prompt()
user_prompt = (
f"## CURRENT MEMORY\n{existing_memory or '(空)'}\n\n"
f"## CONVERSATION CHUNK\n{chunk_text}\n\n"
"僅回傳 JSON,不要其他文字。"
)
response = consolidation_llm.invoke(
[
SystemMessage(content=consolidation_system),
HumanMessage(content=user_prompt),
]
)
content = response.content if isinstance(response.content, str) else str(response.content)
return _parse_consolidation_json(content)
def _consolidate_pack(
consolidation_llm: ChatOpenAI,
chunk: list[BaseMessage],
existing_memory: str,
) -> None:
"""Phase B:整包 chunk + 既有 MEMORY 一次 consolidation;寫 MEMORY/HISTORY。"""
if not chunk:
return
chunk_text = _chunk_to_text(chunk)
max_retries = CONSOLIDATION_MAX_RETRIES
if max_retries <= 0:
fail_note = " ".join(chunk_text.split())[:200]
append_history_log(f"[CONSOLIDATION-FAILED] {fail_note}")
return
for _ in range(max_retries):
parsed = _invoke_consolidation(consolidation_llm, chunk_text, existing_memory)
if parsed is None:
continue
entry = " ".join(parsed["history_entry"].split())
write_memory_md(parsed["memory_update"].strip())
append_history_log(entry)
return
fail_note = " ".join(chunk_text.split())[:200]
append_history_log(f"[CONSOLIDATION-FAILED] {fail_note}")
# ---------------------------------------------------------------------------
# WG-20:SkillsLoader、build_system_prompt(送模唯一入口)
# ---------------------------------------------------------------------------
@dataclass
class SkillEntry:
name: str
path: str
source: str
description: str
always: bool
body: str
def split_frontmatter(text: str) -> tuple[dict[str, str], str]:
if not text.startswith("---"):
return {}, text
lines = text.splitlines()
end: int | None = None
for index in range(1, len(lines)):
if lines[index].strip() == "---":
end = index
break
if end is None:
return {}, text
meta: dict[str, str] = {}
for raw in lines[1:end]:
if ":" not in raw:
continue
key, value = raw.split(":", 1)
meta[key.strip()] = value.strip()
body = "\n".join(lines[end + 1 :]).strip()
return meta, body
class SkillsLoader:
def __init__(self, workspace: Path) -> None:
self.workspace = workspace.resolve()
self.workspace_skills = self.workspace / "skills"
self.builtin_skills = self.workspace / "builtin_skills"
self.workspace_skills.mkdir(parents=True, exist_ok=True)
self.builtin_skills.mkdir(parents=True, exist_ok=True)
def _skill_path_for_read(self, skill_file: Path) -> str:
return skill_file.resolve().relative_to(self.workspace).as_posix()
def _entries_from_dir(
self, root: Path, source: str, skip: set[str]
) -> list[SkillEntry]:
if not root.exists():
return []
entries: list[SkillEntry] = []
for skill_dir in sorted(root.iterdir(), key=lambda p: p.name):
skill_file = skill_dir / "SKILL.md"
if not skill_dir.is_dir() or not skill_file.is_file():
continue
if skill_dir.name in skip:
continue
text = skill_file.read_text(encoding="utf-8")
meta, body = split_frontmatter(text)
name = skill_dir.name
description = meta.get("description") or name
always = meta.get("always", "false").lower() == "true"
rel_path = self._skill_path_for_read(skill_file)
entries.append(
SkillEntry(name, rel_path, source, description, always, body)
)
return entries
def list_skills(self) -> list[SkillEntry]:
workspace_entries = self._entries_from_dir(
self.workspace_skills, "workspace", set()
)
workspace_names = {entry.name for entry in workspace_entries}
builtin_entries = self._entries_from_dir(
self.builtin_skills, "builtin", workspace_names
)
return workspace_entries + builtin_entries
def load_skill(self, name: str) -> str | None:
for root in (self.workspace_skills, self.builtin_skills):
path = root / name / "SKILL.md"
if path.is_file():
return path.read_text(encoding="utf-8")
return None
def build_skills_summary(entries: list[SkillEntry]) -> str:
summarized = [e for e in entries if not e.always]
if not summarized:
return ""
lines = [f"- **{e.name}** — {e.description} `{e.path}`" for e in summarized]
return "\n".join(lines)
SKILLS_LOADER = SkillsLoader(WORKSPACE)
def build_system_prompt() -> str:
"""WG-12~20 送模 system 唯一入口(人設 + 長期記憶 + Skills)。"""
parts: list[str] = [get_runtime_environment(), get_identity()]
mem = memory_block_for_system()
if mem:
parts.append(mem)
entries = SKILLS_LOADER.list_skills()
active = [e for e in entries if e.always]
if active:
body = "\n\n---\n\n".join(
f"### Skill: {e.name}\n\n{e.body}" for e in active
)
parts.append(f"# Active Skills\n\n{body}")
summary = build_skills_summary(entries)
if summary:
intro = (
"下列技能可擴充你的能力。若要使用某技能,請用 read_file 讀取清單中"
"該技能路徑下的 SKILL.md。\n"
"若該技能需額外套件或環境,請先依 SKILL.md 或專案說明安裝相依項目後再操作。\n\n"
)
parts.append("# Skills\n\n" + intro + summary)
return "\n\n---\n\n".join(parts) if len(parts) > 1 else parts[0]
# ---------------------------------------------------------------------------
# WG-19(續):ensure_budget_before_react(ReAct 前;依 WG-17、WG-20 build_system_prompt)
# ---------------------------------------------------------------------------
def ensure_budget_before_react(
consolidation_llm: ChatOpenAI,
history: list[BaseMessage],
last_consolidated: int,
human_message: HumanMessage,
) -> int:
"""WG-19:ReAct 前外層迴圈 — Phase A 規劃 final_idx,Phase B 整包整併 + 推游標。
僅在 cost <= get_token_budget() // 2 時 return;呼叫端可直接進入 ReAct,無需再驗證。
"""
target = get_token_budget() // 2
while True:
# Phase A — 規劃(不呼叫 consolidation LLM)
system_text = build_system_prompt()
past0 = history[last_consolidated:]
cost = len(system_text) + message_cost([*past0, human_message])
if cost <= target:
return last_consolidated
tokens_to_remove = max(0, cost - target)
boundary = pick_consolidation_boundary(
history, last_consolidated, tokens_to_remove
)
if boundary is None or boundary[0] <= last_consolidated:
# 無可用 user 邊界時,整併剩餘全部 history 尾段
if last_consolidated >= len(history):
raise RuntimeError(
f"WG-19:past 已空仍無法壓至 target(cost={cost},target={target})。"
" 請縮短 MEMORY 或調高 TOKEN_BUDGET。"
)
final_idx = len(history)
else:
final_idx = boundary[0]
pack = history[last_consolidated:final_idx]
if not pack:
raise RuntimeError(
f"WG-19:整併包為空無法推進(cost={cost},target={target})。"
)
print(
f"(WG-19 規劃:final_idx={final_idx},"
f"待整併 {len(pack)} 則;cost={cost},target={target}。)"
)
# Phase B — 整包整併 + 推游標(一次 invoke)
existing = read_memory_md()
print(
f"(WG-19 整併:history[{last_consolidated}:{final_idx}]"
f" + MEMORY → memory/MEMORY.md。)"
)
_consolidate_pack(consolidation_llm, pack, existing)
last_consolidated = final_idx
# 回到 Phase A 重算(MEMORY 更新後 system 可能變長)
# ---------------------------------------------------------------------------
# WG-22:`Agent` 封裝(核心對外 API)
# ---------------------------------------------------------------------------
class Agent:
"""WG-12~21 執行邏輯之單一入口;不含 CLI `input()`。"""
def __init__(
self,
*,
session_path: str,
history: list[BaseMessage],
session_meta: dict[str, Any] | None,
last_consolidated: int,
llm: ChatOpenAI,
llm_tools: Any,
) -> None:
self.session_path = session_path
self.history = history
self.session_meta = session_meta
self.last_consolidated = last_consolidated
self.llm = llm
self.llm_tools = llm_tools
@classmethod
def from_env(cls, *, session_path: str | None = None) -> Agent:
load_dotenv()
if not os.getenv("OPENAI_API_KEY"):
raise RuntimeError(
"尚未讀到 OPENAI_API_KEY;請檢查 .env 或系統環境變數。"
)
resolved_path = session_path or os.getenv(
"SESSION_JSONL_PATH", "session.jsonl"
)
history, session_meta = load_session_jsonl(resolved_path)
last_consolidated = (
int(session_meta.get("last_consolidated", 0) or 0)
if session_meta
else 0
)
llm = ChatOpenAI(model="gpt-5.4-mini", temperature=0.2)
llm_tools = llm.bind_tools(TOOLS)
return cls(
session_path=resolved_path,
history=history,
session_meta=session_meta,
last_consolidated=last_consolidated,
llm=llm,
llm_tools=llm_tools,
)
def chat(
self,
user_text: str,
*,
image_path: str | None = None,
on_token: Callable[[str], None] | None = None,
) -> str:
image_rel = image_path
media_type: str | None = None
if image_rel:
try:
media_type = guess_media_type(resolve_project_image_path(image_rel))
except PermissionError:
media_type = None
history_human = history_human_placeholder(user_text, image_rel, media_type)
human_for_send = build_human_message_for_current_turn(user_text, image_rel)
prev_consolidated = self.last_consolidated
self.last_consolidated = ensure_budget_before_react(
self.llm, self.history, self.last_consolidated, history_human
)
if self.last_consolidated != prev_consolidated:
if self.session_meta is None:
self.session_meta = _default_metadata()
self.session_meta = save_session_jsonl(
self.session_path,
self.history,
self.session_meta,
self.last_consolidated,
)
system_text = build_system_prompt()
past = self.history[self.last_consolidated:]
final_text, turn_messages = run_react_turn(
self.llm_tools,
system_text,
past,
human_for_send,
history_human,
on_token=on_token,
)
self.history.extend(turn_messages)
if self.session_meta is None:
self.session_meta = _default_metadata()
self.session_meta = save_session_jsonl(
self.session_path,
self.history,
self.session_meta,
self.last_consolidated,
)
print(
f"(已寫入 {self.session_path!r},共 {len(self.history)} 則累積訊息;"
f" last_consolidated={self.last_consolidated}。)"
)
return final_text
"""
Agent Workshop 標準 CLI(reference_main.py)— peas-workshop-advanced-coach 內唯讀對照。
對應專案根 `main.py`(`uv run main.py`);WG-22 拆檔後標準答案。
僅 CLI 殼層;核心邏輯對照 `reference_agent_core.py`。學生實作勿直接修改本檔。
"""
from __future__ import annotations
from agent_core import Agent, get_token_budget
def main() -> None:
try:
agent = Agent.from_env()
except RuntimeError as e:
print(e)
return
print(
"已讀到 API 金鑰設定(內容不顯示);進入對話"
"(串流 + 工具 + JSONL + 預算裁切 + WG-21 附圖)。"
)
print(
"(WG-21 附圖:先輸入 `/image 相對路徑`,再輸入本輪文字;"
"或單行 `/image 路徑 問題`。)"
)
if agent.history:
print(
f"已從 {agent.session_path!r} 載入 {len(agent.history)} 則訊息(WG-16);"
f" last_consolidated={agent.last_consolidated}(WG-17)。"
)
else:
print("尚無可載入歷史或檔不存在;自空 history 開始(WG-15 寫入)。")
print(
f"(WG-17 TOKEN_BUDGET={get_token_budget()},"
f"WG-19 整併目標 ≤ {get_token_budget() // 2} 字元;以字元長度模擬 token。)"
)
pending_image: str | None = None
while True:
user_line = input("\n你:").strip()
if user_line.lower() in ("quit", "exit", "q"):
print("再見!")
break
if not user_line:
continue
image_rel: str | None = None
user_text = user_line
if user_line.startswith("/image "):
rest = user_line[len("/image ") :].strip()
if not rest:
print(
"(用法:`/image 相對路徑`,下一行輸入文字;"
"或 `/image 路徑 問題`)"
)
continue
parts = rest.split(maxsplit=1)
image_rel = parts[0]
if len(parts) > 1:
user_text = parts[1].strip()
else:
pending_image = image_rel
print(f"(已選附圖 {image_rel!r},請輸入本輪文字)")
continue
elif pending_image is not None:
image_rel = pending_image
pending_image = None
user_text = user_line
if not user_text and not image_rel:
continue
print("\n助手:", end="", flush=True)
agent.chat(user_text, image_path=image_rel)
print()
if __name__ == "__main__":
main()
{"_type": "metadata", "key": "session", "created_at": "2026-03-31T12:00:00.000000", "updated_at": "2026-03-31T12:00:05.000000", "metadata": {}, "last_consolidated": 0}
{"role": "user", "content": "你好", "timestamp": "2026-03-31T12:00:01.000000"}
{"role": "assistant", "content": "你好,有什麼想問的嗎?", "timestamp": "2026-03-31T12:00:02.000000"}
{"role": "user", "content": "幫我計算 3 + 4", "timestamp": "2026-03-31T12:00:03.000000"}
{"role": "assistant", "content": "", "timestamp": "2026-03-31T12:00:04.000000", "tool_calls": [{"name": "add_numbers", "args": {"a": 3, "b": 4}, "id": "call_example_add_001"}]}
{"role": "tool", "content": "7.0", "tool_call_id": "call_example_add_001", "timestamp": "2026-03-31T12:00:04.000000", "name": "add_numbers"}
{"role": "assistant", "content": "3 + 4 = 7", "timestamp": "2026-03-31T12:00:05.000000"}
Bridge Progress Scan: WG-12~22
Use this file to determine the next unfinished workshop challenge before routing to a bridge card.
Scan Target
- Before WG-22 split: scan project-root
main.py. - After split starts or completes: scan
agent_core.pyfor core symbols andmain.pyfor thin CLI behavior. - If
main.pyis missing or empty andagent_core.pyis absent, the skill may copystarter_main_wg21.pytomain.pyand route to WG-22.
How To Compute next_wg
1. Check rows in order. 2. A WG is complete when all required symbols / behaviors for that row are present. 3. next_wg = first incomplete WG. 4. If WG-13~21 are complete and no split is complete, next_wg = 22. 5. If WG-22 checks pass, the advanced workshop is complete; offer verification, reflection, or the separate Dataset Streamlit Shell path if relevant.
Milestones
| WG | Title | Completion Signals |
|---|---|---|
| 12 | system/history separation | System prompt is built at runtime; history exists; serialized session does not depend on storing SystemMessage |
| 13 | tool calling and ReAct loop | get_identity, add_numbers, _stream_model_response, run_react_turn |
| 14 | workspace tools | WORKSPACE, resolve_workspace_path, TOOLS, _run_bound_tool, file tools, exec_workspace |
| 15 | JSONL write | _message_to_jsonl_line, save_session_jsonl; main loop writes after each turn |
| 16 | JSONL load | _row_to_message, load_session_jsonl; startup restores history when file exists |
| 17 | budget trimming | get_token_budget, estimate_message_tokens, message_cost, pick_consolidation_boundary |
| 18 | model transcript adapter | messages_for_model and safe model input repair for tool-call transcripts |
| 19 | memory consolidation | read_memory_md, load_memory_merge_prompt, memory_block_for_system, ensure_budget_before_react |
| 20 | SkillsLoader | SkillEntry, SkillsLoader, SKILLS_LOADER, build_skills_summary, build_system_prompt includes skills |
| 21 | image input | PROJECT_ROOT, resolve_project_image_path, build_human_message_for_current_turn, JSONL image_path, CLI /image or pending_image |
| 22 | core/CLI split | agent_core.py exports Agent; Agent.from_env; Agent.chat; agent_core.py has no input(; thin main.py calls agent.chat and has no core loop definitions |
Preservation Rules
When a WG-13~21 card edits main.py:
- Preserve student nick / display name.
- Preserve persona wording unless the current WG requires a minimal addition.
- Preserve local path choices such as session filename when compatible.
- Do not overwrite whole existing functions when a small merge is enough.
- If a merge is ambiguous, ask before replacing a function with starter-derived content.
Routing Examples
- Highest complete WG is 12 ->
next_wg = 13, readbridge/wg13-react-tools.md. - Highest complete WG is 20 ->
next_wg = 21, readbridge/wg21-image.md. - WG-21 complete and no
Agentsplit ->next_wg = 22, readbridge/wg22-split-core.md. - WG-22 complete -> offer verification, reflection, or the separate Dataset Streamlit Shell path.