Skip to content

feat(writer): chunked bodyMarkdown to avoid mid-string truncation - #22

Open
sebtsang wants to merge 4 commits into
mainfrom
feat/writer-body-chunked-paragraphs
Open

sebtsang wants to merge 4 commits into
mainfrom
feat/writer-body-chunked-paragraphs

Conversation

@sebtsang

Copy link
Copy Markdown
Owner

Summary

Fixes the writer failing on long-form (~2000 word) blog runs with Unterminated string in JSON at position 4590. Root cause: the model hit its per-response max_tokens budget while emitting one giant JSON-escaped string for bodyMarkdown. The Claude Agent SDK doesn't expose max_tokens on its Options type, so we can't bump the cap from the outside.

Fix: change BlogDraftSchema.bodyMarkdown to accept either a single string OR an array of section markdown strings, with a Zod transform that joins paragraphs with a blank line.

bodyMarkdown: z
  .union([z.string(), z.array(z.string())])
  .transform((v) => (Array.isArray(v) ? v.join("\n\n") : v)),

The downstream contract (bodyMarkdown: string) is unchanged — the transform runs inside runtime validation, so DB storage, dashboard rendering, GEO-Editor, Formatter, and the post-approval dispatcher all keep their existing shape. Inferred TypeScript output type stays as string.

Writer prompt updated to encourage the array form for blog-html runs (one entry per H2 section). Keeps the worst single JSON string under ~3KB per entry instead of the post-as-one-blob ~13KB that was truncating.

Provider-agnostic: works under Anthropic and Ollama Cloud.

Test plan

  • pnpm typecheck clean
  • Re-run the prompt that previously failed (Write a deep technical blog post (~2,000 words) for the company at https://composio.dev/...) and confirm the writer produces a valid BlogDraft end-to-end without the JSON-truncation error
  • Confirm outreach_drafts / blog_drafts table still stores bodyMarkdown as a single text column (transform already joined)
  • Confirm GEO-Editor + dashboard preview still render the post correctly

Touch points

Notes

This PR contains ONLY the body-chunking fix. There's separate uncommitted WIP in this worktree (dashboard refactors, new content personas, etc.) which I left alone — that's another session's work.

🤖 Generated with Claude Code

sebtsang and others added 4 commits May 10, 2026 10:34
The writer was failing on long-form (~2000 word) blog runs with
"Unterminated string in JSON at position 4590" because the model hit
its per-response max_tokens budget while emitting one giant
JSON-escaped string for `bodyMarkdown`. The Claude Agent SDK doesn't
expose max_tokens on its Options type, so we can't bump the cap from
the outside.

Fix: change BlogDraftSchema's `bodyMarkdown` to accept either a
single string OR an array of section markdown strings, with a Zod
transform that joins paragraphs with a blank line. The downstream
contract (`bodyMarkdown: string`) is unchanged — the transform runs
inside runtime validation, so DB storage, dashboard rendering,
GEO-Editor, Formatter, and the post-approval dispatcher all keep
their existing shape.

Also updates the writer prompt to encourage the array form for
blog-html runs (one entry per H2 section) — keeps the worst single
JSON string under ~3KB per entry instead of the post-as-one-blob
13KB that was truncating.

Provider-agnostic: works under Anthropic and Ollama Cloud.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptoms: 2,000-word blog runs ended in workflow state "done" with no
actual draft produced. Root cause: writer hit `single-task invocation
exceeded 300s` mid-generation, GEO-Editor + Formatter cascade-skipped,
and pipeline-reporter + slack-digest still ran (triggerRule
"all_done"), which made the run appear successful.

Two changes:

  - SINGLE_TIMEOUT_MS: 300_000 → 600_000. Sonnet 4.6 on a deep-technical
    2K-word draft routinely lands at 4–6 min wall-clock; 300s clipped
    legitimate completions. 600s gives long-form generation real room
    while still failing fast on a genuinely hung model.

  - Per-persona model pin (PERSONA_MODEL_PINS): writer now hard-pinned
    to "claude-sonnet-4-6" regardless of `getModelForTier(tier)` and
    regardless of `GMAESTRO_LLM_PROVIDER`. Flipping the global provider
    env to ollama no longer silently downgrades the writer to Kimi
    K2.6, which can't produce coherent 2K-word drafts.

Caveat: the SDK still reads global ANTHROPIC_BASE_URL, so this pin
assumes the user is on `GMAESTRO_LLM_PROVIDER=anthropic` (current
setup). A future change to env.ts can preserve Anthropic credentials
for pinned-model calls when the global provider is ollama.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BlogDraft approval card's "Send via" picker only listed Reddit,
because PROVIDERS_BY_ARTIFACT.BlogDraft only had a reddit entry. An
internal blog post intended for the company's own static-site repo
had no in-app path to dispatch.

Adds a github entry as the FIRST provider in BlogDraft (so it wins
the auto-default when both reddit and github are connected). It
mirrors the existing ChannelVariant.github 2-step flow (commit file
→ open PR), pulling content directly from the BlogDraft's
bodyMarkdown / title / slug. Repo + branch + path defaults target
the Anvil marketing site; override per-draft via
proposed_action.metadata.{repo, branch, path, prTitle, prBody}.

Verified: approvals page picker now shows "GitHub PR" + "Reddit",
GitHub is auto-selected, approve button reads "Approve & send via
GitHub PR".

Note: this conflicts with a WIP variant of providers.ts (a
different worktree session had set BlogDraft: [] and routed via
Formatter+ChannelVariant). Coordinate with that session before
merging if both designs land at the same time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Conductor and Manager nodes were stuck rendering "RUNNING" forever on
workflows that had clearly terminated, because aggregate() returns
"running" if ANY child is still "running" — and at least one child
specialist routinely lacks a persona_completed event:

  1. The persona failed at exec/parse and the workflow function recorded
     the failure to workflow_nodes but never emitted a persona_completed
     event to the activity bus (e.g. writer timing out at 600s).
  2. Skip-cascade dropped the started→completed pair for a mid-chain
     node whose upstream errored.

Adds a sweep to deriveNodeStatuses: when workflow_done arrives, flip
any remaining "running" nodes to "done". The detailed per-node error
state is still surfaced via workflow_nodes / the popover; the DAG
overview just needed to reflect "this workflow is finished."

Verified on two real runs:
  - 99eb6c9a (Reddit thread): conductor + content-mgr were "running"
    because geo-editor never finished. Now: all "done" except the
    correctly-skipped Formatter.
  - f4276704 (deep blog): conductor + content-mgr were "running"
    because writer timed out at 600s. Now: all "done" except the
    correctly-skipped GEO-Editor + Formatter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant