Skip to content

Commit 4e9b7b2

Browse files
declan-scaleclaude
andcommitted
fix(cli): harden init templates per Greptile feedback (suite-wide)
Addresses the systemic template issues Greptile flagged across the init template PRs (#434/#435/#436), applied consistently to every affected template instead of one family: - manifest.yaml.j2 (19): render `description` via `{{ description | tojson }}` so a user-supplied description containing YAML-significant characters (`:`, `#`, quotes) can no longer produce an invalid or truncated manifest. - Dockerfile-uv.j2 (19): `agentex init` renders `pyproject.toml` but no `uv.lock`, so `COPY ... uv.lock` + `uv sync --locked` broke a fresh uv build. Drop `uv.lock` from the COPY and `--locked` from `uv sync` so a freshly scaffolded project builds out of the box. - acp.py.j2 / workflow.py.j2 (non-text events): `params(.event).content` is a TaskMessageContent union; reading `.content` on a data/tool message raised AttributeError. Guard with `isinstance(content, TextContent)` before reading the text (async handlers return early, sync handlers return a TextContent notice, streaming handlers end the stream). - default-codex acp.py.j2 (concurrency): serialize turns per task with an asyncio lock so two near-simultaneous events can no longer both read a stale `codex_thread_id` and fork the session. (The temporal-codex variant already serializes via its own turn lock.) Verified by rendering every template: all project Python compiles and every manifest parses with a YAML-hostile description value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5dce9f0 commit 4e9b7b2

55 files changed

Lines changed: 268 additions & 160 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.

src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@ ENV UV_HTTP_TIMEOUT=1000
3131
WORKDIR /app/{{ project_path_from_build_root }}
3232

3333
# Copy dependency files for layer caching
34-
COPY {{ project_path_from_build_root }}/pyproject.toml {{ project_path_from_build_root }}/uv.lock ./
34+
COPY {{ project_path_from_build_root }}/pyproject.toml ./
3535

3636
# Install dependencies (without project itself, for layer caching)
3737
RUN --mount=type=cache,target=/root/.cache/uv \
38-
uv sync --locked --no-install-project --no-dev
38+
uv sync --no-install-project --no-dev
3939

4040
# Copy the project code
4141
COPY {{ project_path_from_build_root }}/project ./project
4242

4343
# Install the project
4444
RUN --mount=type=cache,target=/root/.cache/uv \
45-
uv sync --locked --no-dev
45+
uv sync --no-dev
4646

4747
ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH"
4848
ENV PYTHONPATH=/app

src/agentex/lib/cli/templates/default-claude-code/manifest.yaml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ agent:
6565

6666
# Description of what your agent does
6767
# Helps with documentation and discovery
68-
description: {{ description }}
68+
description: {{ description | tojson }}
6969

7070
# Temporal workflow configuration
7171
# Set enabled: true to use Temporal workflows for long-running tasks

src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ from agentex.lib.core.harness import UnifiedEmitter
2727
from agentex.lib.types.fastacp import AsyncACPConfig
2828
from agentex.lib.types.tracing import SGPTracingProcessorConfig
2929
from agentex.lib.utils.logging import make_logger
30+
from agentex.types.text_content import TextContent
3031
from agentex.lib.sdk.fastacp.fastacp import FastACP
3132
from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config
3233

@@ -134,7 +135,11 @@ async def handle_task_create(params: CreateTaskParams):
134135
async def handle_task_event_send(params: SendEventParams):
135136
"""Handle a user message: spawn Claude Code locally and push events to the task stream."""
136137
task_id = params.task.id
137-
prompt = params.event.content.content
138+
content = params.event.content
139+
if not isinstance(content, TextContent):
140+
logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?"))
141+
return
142+
prompt = content.content
138143
logger.info("Processing message for task %s", task_id)
139144

140145
await adk.messages.create(task_id=task_id, content=params.event.content)

src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@ ENV UV_HTTP_TIMEOUT=1000
3131
WORKDIR /app/{{ project_path_from_build_root }}
3232

3333
# Copy dependency files for layer caching
34-
COPY {{ project_path_from_build_root }}/pyproject.toml {{ project_path_from_build_root }}/uv.lock ./
34+
COPY {{ project_path_from_build_root }}/pyproject.toml ./
3535

3636
# Install dependencies (without project itself, for layer caching)
3737
RUN --mount=type=cache,target=/root/.cache/uv \
38-
uv sync --locked --no-install-project --no-dev
38+
uv sync --no-install-project --no-dev
3939

4040
# Copy the project code
4141
COPY {{ project_path_from_build_root }}/project ./project
4242

4343
# Install the project
4444
RUN --mount=type=cache,target=/root/.cache/uv \
45-
uv sync --locked --no-dev
45+
uv sync --no-dev
4646

4747
ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH"
4848
ENV PYTHONPATH=/app

src/agentex/lib/cli/templates/default-codex/manifest.yaml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ agent:
6565

6666
# Description of what your agent does
6767
# Helps with documentation and discovery
68-
description: {{ description }}
68+
description: {{ description | tojson }}
6969

7070
# Temporal workflow configuration
7171
# Set enabled: true to use Temporal workflows for long-running tasks

src/agentex/lib/cli/templates/default-codex/project/acp.py.j2

Lines changed: 94 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ load_dotenv()
3232
import agentex.lib.adk as adk
3333
from agentex.lib.adk import CodexTurn
3434
from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams
35+
from agentex.types.text_content import TextContent
3536
from agentex.lib.core.harness import UnifiedEmitter
3637
from agentex.lib.types.fastacp import AsyncACPConfig
3738
from agentex.lib.types.tracing import SGPTracingProcessorConfig
@@ -57,6 +58,20 @@ acp = FastACP.create(
5758

5859
MODEL = os.environ.get("CODEX_MODEL", "o4-mini")
5960

61+
# Serialize turns per task. Two ``task/event/send`` calls for the same task can
62+
# otherwise both read the old ``codex_thread_id`` (or ``None``), run independent
63+
# codex turns, and race to overwrite the stored thread id — forking the session.
64+
# A per-task lock keeps turns sequential without blocking other tasks.
65+
_task_locks: dict[str, asyncio.Lock] = {}
66+
67+
68+
def _task_lock(task_id: str) -> asyncio.Lock:
69+
lock = _task_locks.get(task_id)
70+
if lock is None:
71+
lock = asyncio.Lock()
72+
_task_locks[task_id] = lock
73+
return lock
74+
6075

6176
class ConversationState(BaseModel):
6277
"""Per-task conversation state persisted via ``adk.state``.
@@ -150,81 +165,93 @@ async def handle_task_event_send(params: SendEventParams):
150165
"""Handle each user message: spawn codex, stream events, save thread ID."""
151166
task_id = params.task.id
152167
agent_id = params.agent.id
153-
user_message = params.event.content.content
154-
155-
logger.info("Processing message for task %s", task_id)
156-
157-
await adk.messages.create(task_id=task_id, content=params.event.content)
158-
159-
task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id)
160-
if task_state is None:
161-
state = ConversationState()
162-
task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state)
163-
else:
164-
state = ConversationState.model_validate(task_state.state)
165168

166-
state.turn_number += 1
169+
content = params.event.content
170+
if not isinstance(content, TextContent):
171+
logger.warning(
172+
"Ignoring non-text event content (type=%s) for task %s",
173+
getattr(content, "type", "?"),
174+
task_id,
175+
)
176+
return
177+
user_message = content.content
167178

168-
async with adk.tracing.span(
169-
trace_id=task_id,
170-
task_id=task_id,
171-
name=f"Turn {state.turn_number}",
172-
input={"message": user_message},
173-
data={"__span_type__": "AGENT_WORKFLOW"},
174-
) as turn_span:
175-
start_ms = int(time.monotonic() * 1000)
179+
logger.info("Processing message for task %s", task_id)
176180

177-
process = await _spawn_codex(MODEL, thread_id=state.codex_thread_id)
181+
await adk.messages.create(task_id=task_id, content=content)
178182

179-
assert process.stdin is not None
180-
process.stdin.write(user_message.encode("utf-8"))
181-
await process.stdin.drain()
182-
process.stdin.close()
183+
# Serialize the read-modify-write of ``codex_thread_id`` so two concurrent
184+
# turns on the same task cannot fork the codex session.
185+
async with _task_lock(task_id):
186+
task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id)
187+
if task_state is None:
188+
state = ConversationState()
189+
task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state)
190+
else:
191+
state = ConversationState.model_validate(task_state.state)
183192

184-
turn = CodexTurn(
185-
events=_process_stdout(process),
186-
model=MODEL,
187-
)
193+
state.turn_number += 1
188194

189-
emitter = UnifiedEmitter(
190-
task_id=task_id,
195+
async with adk.tracing.span(
191196
trace_id=task_id,
192-
parent_span_id=turn_span.id if turn_span else None,
193-
)
194-
195-
# Guarantee the subprocess is reaped even if auto_send_turn raises
196-
# (e.g. a Redis error); otherwise codex stays blocked writing to a full
197-
# stdout pipe buffer and the OS process leaks until the server restarts.
198-
try:
199-
result = await emitter.auto_send_turn(turn)
200-
finally:
201-
if process.returncode is None:
202-
process.kill()
203-
await process.wait()
204-
205-
# Record the real wall-clock duration AFTER streaming completes; setting
206-
# it before the stream ran would capture only subprocess spawn overhead.
207-
turn.duration_ms = int(time.monotonic() * 1000) - start_ms
208-
209-
usage = turn.usage()
210-
211-
# Persist the codex session id (public accessor; valid post-stream) so the
212-
# next turn resumes the same session.
213-
if turn.session_id:
214-
state.codex_thread_id = turn.session_id
215-
216-
await adk.state.update(
217-
state_id=task_state.id,
218197
task_id=task_id,
219-
agent_id=agent_id,
220-
state=state,
221-
)
222-
223-
if turn_span:
224-
turn_span.output = {
225-
"final_text": result.final_text,
226-
"model": usage.model,
227-
}
198+
name=f"Turn {state.turn_number}",
199+
input={"message": user_message},
200+
data={"__span_type__": "AGENT_WORKFLOW"},
201+
) as turn_span:
202+
start_ms = int(time.monotonic() * 1000)
203+
204+
process = await _spawn_codex(MODEL, thread_id=state.codex_thread_id)
205+
206+
assert process.stdin is not None
207+
process.stdin.write(user_message.encode("utf-8"))
208+
await process.stdin.drain()
209+
process.stdin.close()
210+
211+
turn = CodexTurn(
212+
events=_process_stdout(process),
213+
model=MODEL,
214+
)
215+
216+
emitter = UnifiedEmitter(
217+
task_id=task_id,
218+
trace_id=task_id,
219+
parent_span_id=turn_span.id if turn_span else None,
220+
)
221+
222+
# Guarantee the subprocess is reaped even if auto_send_turn raises
223+
# (e.g. a Redis error); otherwise codex stays blocked writing to a full
224+
# stdout pipe buffer and the OS process leaks until the server restarts.
225+
try:
226+
result = await emitter.auto_send_turn(turn)
227+
finally:
228+
if process.returncode is None:
229+
process.kill()
230+
await process.wait()
231+
232+
# Record the real wall-clock duration AFTER streaming completes; setting
233+
# it before the stream ran would capture only subprocess spawn overhead.
234+
turn.duration_ms = int(time.monotonic() * 1000) - start_ms
235+
236+
usage = turn.usage()
237+
238+
# Persist the codex session id (public accessor; valid post-stream) so the
239+
# next turn resumes the same session.
240+
if turn.session_id:
241+
state.codex_thread_id = turn.session_id
242+
243+
await adk.state.update(
244+
state_id=task_state.id,
245+
task_id=task_id,
246+
agent_id=agent_id,
247+
state=state,
248+
)
249+
250+
if turn_span:
251+
turn_span.output = {
252+
"final_text": result.final_text,
253+
"model": usage.model,
254+
}
228255

229256

230257
@acp.on_task_cancel

src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,18 @@ ENV UV_HTTP_TIMEOUT=1000
2727
WORKDIR /app/{{ project_path_from_build_root }}
2828

2929
# Copy dependency files for layer caching
30-
COPY {{ project_path_from_build_root }}/pyproject.toml {{ project_path_from_build_root }}/uv.lock ./
30+
COPY {{ project_path_from_build_root }}/pyproject.toml ./
3131

3232
# Install dependencies (without project itself, for layer caching)
3333
RUN --mount=type=cache,target=/root/.cache/uv \
34-
uv sync --locked --no-install-project --no-dev
34+
uv sync --no-install-project --no-dev
3535

3636
# Copy the project code
3737
COPY {{ project_path_from_build_root }}/project ./project
3838

3939
# Install the project
4040
RUN --mount=type=cache,target=/root/.cache/uv \
41-
uv sync --locked --no-dev
41+
uv sync --no-dev
4242

4343
ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH"
4444
ENV PYTHONPATH=/app

src/agentex/lib/cli/templates/default-langgraph/manifest.yaml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ agent:
6565

6666
# Description of what your agent does
6767
# Helps with documentation and discovery
68-
description: {{ description }}
68+
description: {{ description | tojson }}
6969

7070
# Temporal workflow configuration
7171
# Set enabled: true to use Temporal workflows for long-running tasks

src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ from agentex.protocol.acp import SendEventParams, CancelTaskParams, CreateTaskPa
2222
from agentex.lib.types.fastacp import AsyncACPConfig
2323
from agentex.lib.types.tracing import SGPTracingProcessorConfig
2424
from agentex.lib.utils.logging import make_logger
25+
from agentex.types.text_content import TextContent
2526
from agentex.lib.adk import LangGraphTurn
2627

2728
from project.graph import create_graph
@@ -55,7 +56,11 @@ async def handle_task_event_send(params: SendEventParams):
5556
"""Handle incoming events, streaming tokens and tool calls via Redis."""
5657
graph = await get_graph()
5758
task_id = params.task.id
58-
user_message = params.event.content.content
59+
content = params.event.content
60+
if not isinstance(content, TextContent):
61+
logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?"))
62+
return
63+
user_message = content.content
5964

6065
logger.info(f"Processing message for thread {task_id}")
6166

src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,18 @@ ENV UV_HTTP_TIMEOUT=1000
2727
WORKDIR /app/{{ project_path_from_build_root }}
2828

2929
# Copy dependency files for layer caching
30-
COPY {{ project_path_from_build_root }}/pyproject.toml {{ project_path_from_build_root }}/uv.lock ./
30+
COPY {{ project_path_from_build_root }}/pyproject.toml ./
3131

3232
# Install dependencies (without project itself, for layer caching)
3333
RUN --mount=type=cache,target=/root/.cache/uv \
34-
uv sync --locked --no-install-project --no-dev
34+
uv sync --no-install-project --no-dev
3535

3636
# Copy the project code
3737
COPY {{ project_path_from_build_root }}/project ./project
3838

3939
# Install the project
4040
RUN --mount=type=cache,target=/root/.cache/uv \
41-
uv sync --locked --no-dev
41+
uv sync --no-dev
4242

4343
ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH"
4444
ENV PYTHONPATH=/app

0 commit comments

Comments
 (0)