Skip to content

Commit 2fe3eae

Browse files
neoneyeclaude
andcommitted
Merge branch 'main' into feature/109-retry-improvements
Bring in latest main changes including usage metrics (#110), LLMChatError traceability (#237), ThinkingAwareOpenAILike (#238), pipeline versioning, plan resume improvements, and error classification in usage metrics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 314b9ed + e1c8d38 commit 2fe3eae

67 files changed

Lines changed: 1629 additions & 200 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/ai_providers/lm_studio.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,46 @@ ssh -N -L 1234:localhost:1234 user@remote-host
8787

8888
Then set `base_url` to `http://localhost:1234` while the tunnel is running.
8989

90+
## Thinking Tokens and Reasoning Content
91+
92+
Some advanced models (like Qwen3.5 with extended thinking) can be configured in LM Studio to emit **thinking tokens** — internal chain-of-thought reasoning that doesn't appear in the final output.
93+
94+
### The Gotcha
95+
96+
When thinking tokens are enabled:
97+
- The model may return `reasoning_content` (internal reasoning) but `content: null` (final output empty)
98+
- llama_index's standard `OpenAILike` class only reads `content`, causing a crash if it's None
99+
100+
### Solution
101+
102+
Use the `ThinkingAwareOpenAILike` class instead of `OpenAILike` in your config:
103+
104+
```json
105+
"lmstudio-qwen3.5-9b-with-thinking": {
106+
"class": "ThinkingAwareOpenAILike",
107+
"arguments": {
108+
"model": "qwen/qwen3.5-9b",
109+
"api_base": "http://127.0.0.1:1234/v1",
110+
"api_key": "lm-studio",
111+
"temperature": 0.55,
112+
"timeout": 600.0,
113+
"is_function_calling_model": false,
114+
"is_chat_model": true,
115+
"context_window": 32768,
116+
"max_tokens": 4096,
117+
"should_use_structured_outputs": true
118+
}
119+
}
120+
```
121+
122+
`ThinkingAwareOpenAILike` safely handles both:
123+
1. **With thinking:** Falls back to `reasoning_content` if `content` is null
124+
2. **Without thinking:** Works exactly like `OpenAILike` for normal responses
125+
126+
### Best Practice
127+
128+
If you're enabling thinking in LM Studio (e.g., via the Developer preset), always use `ThinkingAwareOpenAILike` to avoid crashes. See [issue #228](https://github.com/PlanExeOrg/PlanExe/pull/228) for technical details.
129+
90130
---
91131

92132
## Next steps

‎docs/mcp/planexe_mcp_interface.md‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The plan is a **project plan**: a DAG of steps (Luigi pipeline stages) that prod
1515
Implementors should expose the following to agents so they understand what PlanExe does:
1616

1717
- **What:** PlanExe turns a plain-English goal into a strategic project-plan draft (20+ sections) in ~10–20 min. Sections include executive summary, interactive Gantt charts, investor pitch, SWOT, governance, team profiles, work breakdown, scenario comparison, expert criticism, and adversarial sections (premortem, self-audit, premise attacks) that stress-test the plan. The output is a draft to refine, not an executable or final document — but it surfaces hard questions the prompter may not have considered.
18-
- **Required interaction order:** Call `example_plans` (optional) and `example_prompts` first. Optional before `plan_create`: call `model_profiles` to inspect profile guidance and available models in each profile. Then complete a non-tool step: formulate a detailed prompt as flowing prose (not structured markdown), typically ~300-800 words, using the examples as a baseline; include objective, scope, constraints, timeline, stakeholders, budget/resources, and success criteria; get user approval. Only after approval, call `plan_create`. Then poll `plan_status` (about every 5 minutes); use `plan_download` (mcp_local helper) or `plan_file_info` (mcp_cloud tool) when complete (`pending`/`processing` = keep polling, `completed` = download now, `failed` = terminal). If a plan fails mid-pipeline, call `plan_resume` to continue from where it left off without discarding completed tasks. Use `plan_retry` for a full restart. Both accept the failed `plan_id` and optional `model_profile` (default `baseline`). To stop, call `plan_stop` with the `plan_id` from `plan_create`.
18+
- **Required interaction order:** Call `example_plans` (optional) and `example_prompts` first. Optional before `plan_create`: call `model_profiles` to inspect profile guidance and available models in each profile. Then complete a non-tool step: formulate a detailed prompt as flowing prose (not structured markdown), typically ~300-800 words, using the examples as a baseline; include objective, scope, constraints, timeline, stakeholders, budget/resources, and success criteria; get user approval. Only after approval, call `plan_create`. Then poll `plan_status` (about every 5 minutes); use `plan_download` (mcp_local helper) or `plan_file_info` (mcp_cloud tool) when complete (`pending`/`processing` = keep polling, `completed` = download now, `failed` = terminal). If a plan fails before completing all steps, call `plan_resume` to continue from where it left off without discarding completed work. Use `plan_retry` for a full restart (plan must be in failed state). Both accept the failed `plan_id` and optional `model_profile` (default `baseline`). To stop, call `plan_stop` with the `plan_id` from `plan_create`.
1919
- **Output:** Self-contained interactive HTML report (~700KB) with collapsible sections and interactive Gantt charts — open in a browser. The zip contains the intermediary pipeline files (md, json, csv) that fed the report.
2020

2121
### 1.3 Scope of this document
@@ -438,9 +438,9 @@ Retries a plan that is currently in `failed` state.
438438

439439
### 6.6 plan_resume
440440

441-
Resume a failed plan without discarding completed pipeline outputs. The pipeline restarts from the first incomplete task, skipping all tasks that already produced output files.
441+
Resume a failed plan without discarding completed intermediary files. Plan generation restarts from the first incomplete step, skipping all steps that already produced output files.
442442

443-
Use `plan_resume` when `plan_status` shows `failed` and the run was interrupted mid-pipeline (network drop, timeout, `plan_stop`, worker crash). For a full restart or to change `model_profile`, use `plan_retry` instead.
443+
Use `plan_resume` when `plan_status` shows `failed` and plan generation was interrupted before completing all steps (network drop, timeout, `plan_stop`, worker crash). For a full restart or to change `model_profile`, use `plan_retry` instead.
444444

445445
**Request**
446446

@@ -470,25 +470,42 @@ Use `plan_resume` when `plan_status` shows `failed` and the run was interrupted
470470

471471
**Required semantics**
472472

473-
- Only failed plans are resumable.
473+
- The MCP tool only accepts plans in `failed` state. However, the underlying Luigi mechanism is more general: Luigi skips any task whose output file already exists and re-executes any task whose output file is missing. This means a completed plan can be partially re-run by deleting `999-pipeline_complete.txt` and the output files of the tasks you want to regenerate — Luigi will re-execute those tasks and all their downstream dependents. The MCP API does not yet expose this capability; it is available when running the pipeline locally via `run_plan_pipeline.py`.
474474
- On success, the same plan_id is reset to `pending` and requeued.
475-
- Prior artifacts are **preserved** — the worker restores the run directory from the stored zip snapshot.
476-
- Luigi skips tasks whose output files already exist; only incomplete tasks are re-executed.
475+
- Prior artifacts are **preserved** — the worker restores the output directory from the stored zip snapshot.
477476
- `resume_count` tracks how many times the plan has been resumed.
478477

479478
**When to use plan_resume vs plan_retry**
480479

481480
| Scenario | Use |
482481
|----------|-----|
483-
| Run interrupted mid-pipeline (network drop, timeout, stop, crash) | `plan_resume` |
482+
| Interrupted before completing all steps (network drop, timeout, stop, crash) | `plan_resume` |
484483
| Full restart from scratch | `plan_retry` |
485-
| Change model_profile for a fresh run | `plan_retry` |
484+
| Change model_profile for a fresh start | `plan_retry` |
486485
| Continue where you left off, preserving completed work | `plan_resume` |
487486

487+
**Pipeline compatibility caveat**
488+
489+
Resuming restores the output directory from a stored zip snapshot and relies on Luigi's file-based step skipping: if an output file exists, the step is skipped. This works reliably when the server code matches the code that produced the snapshot. It can break when the server has changed between the original plan generation and the resume attempt:
490+
491+
- **Renamed or removed filenames** (`FilenameEnum` in `filenames.py`): if a step's output filename changes, Luigi won't find the old file and will re-execute the step — but downstream steps that already completed may reference the old filename's content, producing inconsistencies.
492+
- **New steps added**: new steps may expect upstream outputs that don't exist in the snapshot.
493+
- **Changed step dependencies**: if the DAG wiring changes, Luigi may execute steps in a different order or skip steps that should re-execute.
494+
- **Changed JSON schemas**: resumed steps may read snapshot files whose internal structure no longer matches what the current code expects.
495+
496+
In practice, resuming a plan that failed minutes or hours ago on the same deployment is safe. Resuming a plan that is weeks or months old — after the server has been updated with pipeline changes — may produce errors or inconsistent output. When in doubt, use `plan_retry` for a clean restart.
497+
498+
**Pipeline version check**
499+
500+
A `PIPELINE_VERSION` integer constant (in `worker_plan_api/pipeline_version.py`) is stamped into the plan's `parameters` on every `plan_create`, `plan_retry`, and `plan_resume`. On `plan_resume`, the stored version is compared to the current constant using strict equality. If they differ, resume is rejected with error code `PIPELINE_VERSION_MISMATCH` and the caller should use `plan_retry` for a clean restart. Legacy plans with no stored version are treated as incompatible.
501+
502+
Bump `PIPELINE_VERSION` whenever the pipeline changes in a way that would break resume from an older snapshot (renamed filenames, new tasks, changed DAG wiring, changed JSON schemas). Finer-grained approaches (manifest, per-task schema versions) can be layered on later if needed.
503+
488504
**Error behavior**
489505

490506
- Unknown plan_id: `PLAN_NOT_FOUND` (`isError=true`).
491507
- Plan not failed: `PLAN_NOT_RESUMABLE` (`isError=true`).
508+
- Pipeline version mismatch: `PIPELINE_VERSION_MISMATCH` (`isError=true`). Use `plan_retry` instead.
492509

493510
---
494511

@@ -594,6 +611,7 @@ Cloud/core tool codes:
594611
- `PLAN_NOT_FOUND`: plan_id not found.
595612
- `PLAN_NOT_FAILED`: plan_retry called for a plan that is not in failed state.
596613
- `PLAN_NOT_RESUMABLE`: plan_resume called for a plan that is not in failed state.
614+
- `PIPELINE_VERSION_MISMATCH`: plan_resume snapshot was created by a different pipeline version; use plan_retry instead.
597615
- `INVALID_USER_API_KEY`: provided user_api_key is invalid.
598616
- `USER_API_KEY_REQUIRED`: deployment requires user_api_key for plan_create.
599617
- `INSUFFICIENT_CREDITS`: caller account has no credits for plan_create.
@@ -619,6 +637,7 @@ Local proxy specific codes:
619637
- `INVALID_TOOL`
620638
- For `PLAN_NOT_FAILED`: call `plan_retry` only after `plan_status.state == failed`.
621639
- For `PLAN_NOT_RESUMABLE`: call `plan_resume` only after `plan_status.state == failed`.
640+
- For `PIPELINE_VERSION_MISMATCH`: the snapshot is incompatible with the current pipeline; use `plan_retry` for a clean restart.
622641
- For `PLAN_NOT_FOUND`: verify plan_id source and stop polling that id.
623642
- For `generation_failed`: treat as terminal failure and surface plan progress_message to user.
624643

‎docs/proposals/103-pipeline-hardening-roadmap-local-model-reliability.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,63 @@ The target is practical: reduce repeated run failures caused by structured-outpu
4242
3. **Schema echo** (model repeats schema shape/descriptions, not values).
4343
4. **Code-path assumption bugs** (`KeyError` on non-API path).
4444

45+
### 1.4 Local model testing findings: llama_index silent prompt truncation (March 2026)
46+
47+
#### Root cause: llama_index default constants
48+
49+
Testing on Mac Mini M4 Pro (March 4-6, 2026) with qwen/qwen3.5-35b-a3b via LM Studio revealed the underlying cause of silent structured-output failures:
50+
51+
**File**: `llama_index/core/constants.py`
52+
```python
53+
DEFAULT_CONTEXT_WINDOW = 3900 # applies to ALL OpenAILike models unless overridden
54+
DEFAULT_NUM_OUTPUT = 256 # affects PromptHelper budget, NOT model output limit
55+
```
56+
57+
These defaults apply to **all OpenAILike models** unless explicitly overridden in the model config. The `PromptHelper` computes:
58+
```
59+
available_prompt_budget = context_window - num_output = 3900 - 256 = 3644 tokens
60+
```
61+
62+
#### The failure mode
63+
64+
`DeduplicateLeversTask` input was ~8,400 tokens. With the default budget of 3644 tokens, the prompt was **silently truncated to 3644 tokens** before being sent to the model. The model received a mangled, mid-sentence prompt, produced thinking text instead of valid JSON, and the pipeline failed with `ValueError: Could not extract json string`.
65+
66+
This is **silent** — no warning, no log entry, no error at truncation time. The failure manifests downstream as a JSON extraction error, making it extremely hard to diagnose.
67+
68+
#### Critical insight: `num_output` does NOT limit model output
69+
70+
A common misunderstanding: setting `num_output: 4096` does NOT cap the model's actual output length. The LM Studio API generates freely (10K+ tokens observed). The limit ONLY affects llama_index's internal `PromptHelper` budget calculation for **input** truncation. This is the root cause of misdiagnosis.
71+
72+
#### The fix
73+
74+
Add explicit overrides to each LM Studio model entry in `llm_config/custom.json`:
75+
```json
76+
"context_window": 8192,
77+
"num_output": 4096
78+
```
79+
80+
This expands the available prompt budget from 3644 to 4096 tokens, accommodating tasks with large combined inputs. For models with larger context windows (e.g., 32K), use `context_window: 32768, num_output: 4096`.
81+
82+
#### Tasks most vulnerable to silent truncation
83+
84+
Tasks with large combined inputs (multiple prior outputs concatenated):
85+
- `DeduplicateLeversTask` — ~8,400 tokens input (confirmed failure point)
86+
- `PremortemTask` / `ReviewPlanTask` / `QuestionsAndAnswersTask` — 10-12 docs concatenated
87+
- `GovernancePhase4-6` — accumulating context chain
88+
89+
#### Detection method
90+
91+
To verify if your deployment is vulnerable:
92+
```bash
93+
grep -r "DEFAULT_CONTEXT_WINDOW\|DEFAULT_NUM_OUTPUT" $(pip show llama-index-core | grep Location | cut -d' ' -f2)/llama_index/core/constants.py
94+
```
95+
96+
If values are 3900/256 and your model config doesn't override them, you're vulnerable to silent truncation.
97+
98+
#### Relationship to this roadmap
99+
100+
Silent truncation is exactly the class of mid-pipeline failures that the containment and telemetry work packages (WP-1, WP-2, WP-3) are designed to catch and surface. By adding structured failure logging (WP-3), future truncation events will be diagnosable instantly. By adding a preflight smoke test (WP-5), bad model profiles can be caught before expensive full-pipeline runs.
101+
45102
---
46103

47104
## 2) Guardrails and merge policy

‎docs/proposals/110-usage-metrics-local-runs.md‎

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Author:** Bubba (VoynichLabs)
44
**Date:** 2026-03-08
5-
**Status:** Proposal
5+
**Status:** Implemented (PR #219)
66

77
---
88

@@ -165,18 +165,90 @@ None of these are present in a bare CLI run. The file-based approach requires on
165165

166166
---
167167

168-
## Open Questions
168+
## Implementation Notes (PR #219)
169169

170-
1. **Ollama field mapping**: `prompt_eval_count` / `eval_count` vs `prompt_tokens` /
171-
`completion_tokens` — should `extract_token_count()` add an Ollama-specific branch, or
172-
should the Ollama LLM adapter normalize field names before returning?
170+
The final implementation diverges from Option A above in several ways:
173171

174-
2. **Thread safety**: Luigi runs tasks in parallel workers. The appender needs an
175-
`fcntl.flock()` or atomic rename approach to avoid corruption under concurrent writes.
172+
### File format: JSONL, not JSON
176173

177-
3. **Thinking tokens for local models**: Qwen 3 thinking tokens are stripped before the
178-
response reaches llama_index (LM Studio does not expose them separately). The
179-
`thinking_tokens` field will be `null` for local runs — acceptable for now.
174+
`usage_metrics.jsonl` uses one JSON object per line (append-only). This avoids the need for
175+
atomic rename or in-memory accumulation — each LLM call appends a single line. Thread-safe
176+
by nature since each write is a short append to a file handle.
180177

181-
4. **Retention**: Should `usage_metrics.json` be overwritten on resume (Luigi skip-completed
182-
behavior) or appended? Appending is safer — a resumed run adds only the new calls.
178+
### Recording source: llama_index instrumentation, not LLMExecutor
179+
180+
Successful calls are recorded by `TrackActivity` (the llama_index `BaseEventHandler`) which
181+
receives the actual `ChatResponse` with full token counts, cost, and `provider:model` info.
182+
`LLMExecutor._record_attempt_token_metrics()` only records **failures**, since instrumentation
183+
end events are not emitted when the LLM call fails.
184+
185+
This was necessary because `execute_function(llm)` returns the processed result (a Pydantic
186+
model or string), not the raw `ChatResponse`. The instrumentation layer is the only place
187+
with access to the real response.
188+
189+
### Model field includes provider
190+
191+
The `model` field contains the full `provider:model` string (e.g.
192+
`Google AI Studio:google/gemini-2.0-flash-001`), matching `activity_overview.json`.
193+
194+
### Example output
195+
196+
```json
197+
{"timestamp": "2026-03-10T13:36:48.250446", "success": true, "model": "Google AI Studio:google/gemini-2.0-flash-001", "duration_seconds": 4.879, "input_tokens": 5316, "output_tokens": 643, "cost_usd": 0.0007888}
198+
{"timestamp": "2026-03-10T13:36:53.554864", "success": true, "model": "Google:google/gemini-2.0-flash-001", "duration_seconds": 5.237, "input_tokens": 8877, "output_tokens": 562, "cost_usd": 0.0011125}
199+
```
200+
201+
### Key files
202+
203+
| File | Role |
204+
|------|------|
205+
| `worker_plan/worker_plan_internal/llm_util/usage_metrics.py` | Core module: `set_usage_metrics_path()`, `record_usage_metric()` |
206+
| `worker_plan/worker_plan_internal/llm_util/track_activity.py` | Records successful calls via `_record_file_usage_metric()` |
207+
| `worker_plan/worker_plan_internal/llm_util/llm_executor.py` | Records failed calls only |
208+
| `worker_plan/worker_plan_internal/plan/run_plan_pipeline.py` | Sets/clears metrics path around pipeline execution |
209+
| `worker_plan/worker_plan_api/filenames.py` | `USAGE_METRICS_JSONL` constant |
210+
211+
### Resolved open questions
212+
213+
1. **Ollama field mapping**: Handled by `extract_token_count()` and `TrackActivity._extract_token_usage()` which already support multiple field name variations.
214+
2. **Thread safety**: JSONL append-per-line is safe for concurrent Luigi workers.
215+
3. **Thinking tokens**: Recorded when available (e.g. from OpenRouter reasoning models). `null` for providers that don't expose them.
216+
4. **Retention on resume**: Appended. A resumed run adds only the new calls alongside the restored snapshot's existing metrics.
217+
218+
---
219+
220+
## Follow-up: Error Classification (PR #236)
221+
222+
Raw Python exception strings in the `"error"` field were verbose and could leak raw model output.
223+
PR #236 added `classify_error()` to replace them with short category labels:
224+
225+
| Category | Matches on |
226+
|---|---|
227+
| `invalid_json` | "json", "validation error", "pydantic" |
228+
| `timeout` | "timeout", "timed out" |
229+
| `empty_response` | "empty", "none", "no response" |
230+
| `connection_error` | "connection", "connect", "dns", "network" |
231+
| `rate_limit` | "rate limit", "429", "too many requests" |
232+
| `auth_error` | "auth", "401", "403", "unauthorized", "forbidden" |
233+
| `server_error` | "500", "502", "503", "504", "server error", "internal server" |
234+
| `model_not_found` | "model not found", "not found", "404" |
235+
| `unknown` | fallback — raw message preserved in `error_detail` (truncated to 200 chars) |
236+
237+
---
238+
239+
## Follow-up: Error Traceability (PR #237)
240+
241+
All 38 call sites that raised `ValueError("LLM chat interaction failed.")` were converted to
242+
`LLMChatError` (see [proposal 113](113-llm-error-traceability.md)). This:
243+
244+
- **Preserves the root cause** in `str(LLMChatError)`, so `classify_error()` categorizes correctly
245+
(previously all were `"unknown"`).
246+
- **Adds `error_id`** (12-char UUID) to both log output and the JSONL row, enabling cross-referencing.
247+
248+
Example failure row after both PRs:
249+
250+
```json
251+
{"timestamp": "2026-03-10T19:50:18.821350", "success": false, "model": "openrouter-gemini-2.0-flash-001", "duration_seconds": 5.391, "error": "invalid_json", "error_id": "4c2a64973bcd"}
252+
```
253+
254+
A user can `grep 4c2a64973bcd` in the logs to find the full traceback.

0 commit comments

Comments
 (0)