GeezerVison is showing github.com — fix(templates): bound Gemini CLI shutdown and read long stream-json l… · scaleapi/scale-agentex-python@583856c
File treeExpand file treeCollapse file tree Open diff view settings src/agentex/lib/cli/templates
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/default-gemini-cli/Dockerfile-uv.j2 +21-1Lines changed: 21 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -20,7 +20,7 @@ RUN apt-get update && apt-get install -y \ | 20 | 20 | && apt-get clean \ | 21 | 21 | && rm -rf /var/lib/apt/lists/** | 22 | 22 |
| 23 | | -# Install the Gemini CLI CLI: the agent shells out to `gemini` on every turn, | | 23 | +# Install the Gemini CLI: the agent shells out to `gemini` on every turn, | 24 | 24 | # so the binary must be present in the runtime image. | 25 | 25 | RUN npm install -g @google/gemini-cli | 26 | 26 |
| @@ -34,14 +34,34 @@ WORKDIR /app/{{ project_path_from_build_root }} | 34 | 34 | COPY {{ project_path_from_build_root }}/pyproject.toml ./ | 35 | 35 |
| 36 | 36 | # Install dependencies (without project itself, for layer caching) | | 37 | +# Optional private index for Scale-internal packages such as sgp-obs, injected by the | | 38 | +# control-plane broker. Inert unless the secret is present, so local | | 39 | +# builds, CI builds, and agents that never opt in are unaffected. | | 40 | +# | | 41 | +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting | | 42 | +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. | 37 | 43 | RUN --mount=type=cache,target=/root/.cache/uv \ | | 44 | + --mount=type=secret,id=codeartifact-pip-conf,required=false \ | | 45 | + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ | | 46 | + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ | | 47 | + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ | | 48 | + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | | 49 | + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ | | 50 | + fi; \ | 38 | 51 | uv sync --no-install-project --no-dev | 39 | 52 |
| 40 | 53 | # Copy the project code | 41 | 54 | COPY {{ project_path_from_build_root }}/project ./project | 42 | 55 |
| 43 | 56 | # Install the project | 44 | 57 | RUN --mount=type=cache,target=/root/.cache/uv \ | | 58 | + --mount=type=secret,id=codeartifact-pip-conf,required=false \ | | 59 | + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ | | 60 | + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ | | 61 | + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ | | 62 | + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | | 63 | + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ | | 64 | + fi; \ | 45 | 65 | uv sync --no-dev | 46 | 66 |
| 47 | 67 | ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/default-gemini-cli/Dockerfile.j2 +13-2Lines changed: 13 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -20,7 +20,7 @@ RUN apt-get update && apt-get install -y \ | 20 | 20 | && apt-get clean \ | 21 | 21 | && rm -rf /var/lib/apt/lists/* | 22 | 22 |
| 23 | | -# Install the Gemini CLI CLI: the agent shells out to `gemini` on every turn, | | 23 | +# Install the Gemini CLI: the agent shells out to `gemini` on every turn, | 24 | 24 | # so the binary must be present in the runtime image. | 25 | 25 | RUN npm install -g @google/gemini-cli | 26 | 26 |
| @@ -33,8 +33,19 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr | 33 | 33 |
| 34 | 34 | WORKDIR /app/{{ project_path_from_build_root }} | 35 | 35 |
| | 36 | +# Optional private index for Scale-internal packages such as sgp-obs, injected by the | | 37 | +# control-plane broker. Inert unless the secret is present. | | 38 | +# | | 39 | +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to | | 40 | +# read a named index out of; the credentialed URL is used directly and is deliberately | | 41 | +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. | | 42 | +# | 36 | 43 | # Install the required Python packages | 37 | | -RUN uv pip install --system -r requirements.txt | | 44 | +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ | | 45 | + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ | | 46 | + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ | | 47 | + fi; \ | | 48 | + uv pip install --system -r requirements.txt | 38 | 49 |
| 39 | 50 | # Copy the project code | 40 | 51 | COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/default-gemini-cli/README.md.j2 +2-2Lines changed: 2 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | # {{ agent_name }} - AgentEx Async Gemini CLI Agent | 2 | 2 |
| 3 | 3 | This template builds an **asynchronous** (non-Temporal) agent that drives the | 4 | | -**Gemini CLI CLI** through the unified harness surface on AgentEx: | | 4 | +**Gemini CLI** through the unified harness surface on AgentEx: | 5 | 5 | - Spawns `gemini -p "<prompt>" --output-format stream-json` as a local subprocess | 6 | 6 | - Wraps the CLI's stdout stream in a `GeminiCliTurn` | 7 | 7 | - Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` | @@ -40,7 +40,7 @@ agentex agents run --manifest manifest.yaml | 40 | 40 |
| 41 | 41 | ### Async ACP with the harness | 42 | 42 | The async ACP model streams events over Redis instead of an HTTP response. The | 43 | | -`@acp.on_task_event_send` handler spawns the Gemini CLI CLI and pushes the | | 43 | +`@acp.on_task_event_send` handler spawns the Gemini CLI and pushes the | 44 | 44 | harness events to the task stream. | 45 | 45 |
| 46 | 46 | ### The unified harness surface | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/default-gemini-cli/manifest.yaml.j2 +1-1Lines changed: 1 addition & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 76 | 76 | # Maps Kubernetes secrets to environment variables | 77 | 77 | # Common credentials include: | 78 | 78 | credentials: | 79 | | - # The Gemini CLI CLI authenticates with GEMINI_API_KEY (LITELLM_API_KEY | | 79 | + # The Gemini CLI authenticates with GEMINI_API_KEY (LITELLM_API_KEY | 80 | 80 | # is not read by the `gemini` subprocess this agent spawns). | 81 | 81 | - env_var_name: GEMINI_API_KEY | 82 | 82 | secret_name: gemini-api-key | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/default-gemini-cli/project/acp.py.j2 +17-1Lines changed: 17 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -33,6 +33,9 @@ from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_proce | 33 | 33 |
| 34 | 34 | logger = make_logger(__name__) | 35 | 35 |
| | 36 | +STDOUT_LINE_LIMIT = 8 * 1024 * 1024 | | 37 | +TERMINATE_TIMEOUT_SECONDS = 5.0 | | 38 | + | 36 | 39 | add_tracing_processor_config( | 37 | 40 | SGPTracingProcessorConfig( | 38 | 41 | sgp_api_key=os.environ.get("SGP_API_KEY", ""), | @@ -52,6 +55,11 @@ async def _spawn_gemini(prompt: str) -> AsyncIterator[str]: | 52 | 55 |
| 53 | 56 | Injectable seam: tests can monkeypatch this with a fake async iterator of | 54 | 57 | pre-recorded lines so no real CLI invocation is needed offline. | | 58 | + | | 59 | + Lines up to ``STDOUT_LINE_LIMIT`` are read whole: a ``tool_result`` that | | 60 | + echoes a file is a single stream-json line, often past asyncio's 64 KiB | | 61 | + default. If the consumer stops early, the CLI gets SIGTERM and, after | | 62 | + ``TERMINATE_TIMEOUT_SECONDS``, SIGKILL, so cancellation cannot hang. | 55 | 63 | """ | 56 | 64 | # The prompt goes in via ``-p`` (argv). Stdin is closed on purpose: in | 57 | 65 | # non-interactive mode the CLI reads stdin to EOF and appends it to the | @@ -67,6 +75,7 @@ async def _spawn_gemini(prompt: str) -> AsyncIterator[str]: | 67 | 75 | stdin=asyncio.subprocess.DEVNULL, | 68 | 76 | stdout=asyncio.subprocess.PIPE, | 69 | 77 | stderr=asyncio.subprocess.PIPE, | | 78 | + limit=STDOUT_LINE_LIMIT, | 70 | 79 | ) | 71 | 80 | assert proc.stdout is not None | 72 | 81 |
| @@ -123,7 +132,14 @@ async def _spawn_gemini(prompt: str) -> AsyncIterator[str]: | 123 | 132 | proc.terminate() | 124 | 133 | except ProcessLookupError: | 125 | 134 | pass | 126 | | - await proc.wait() | | 135 | + try: | | 136 | + await asyncio.wait_for(proc.wait(), timeout=TERMINATE_TIMEOUT_SECONDS) | | 137 | + except TimeoutError: | | 138 | + try: | | 139 | + proc.kill() | | 140 | + except ProcessLookupError: | | 141 | + pass | | 142 | + await proc.wait() | 127 | 143 |
| 128 | 144 |
| 129 | 145 | @acp.on_task_create | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/sync-gemini-cli/Dockerfile-uv.j2 +21-1Lines changed: 21 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -20,7 +20,7 @@ RUN apt-get update && apt-get install -y \ | 20 | 20 | && apt-get clean \ | 21 | 21 | && rm -rf /var/lib/apt/lists/** | 22 | 22 |
| 23 | | -# Install the Gemini CLI CLI: the agent shells out to `gemini` on every turn, | | 23 | +# Install the Gemini CLI: the agent shells out to `gemini` on every turn, | 24 | 24 | # so the binary must be present in the runtime image. | 25 | 25 | RUN npm install -g @google/gemini-cli | 26 | 26 |
| @@ -34,14 +34,34 @@ WORKDIR /app/{{ project_path_from_build_root }} | 34 | 34 | COPY {{ project_path_from_build_root }}/pyproject.toml ./ | 35 | 35 |
| 36 | 36 | # Install dependencies (without project itself, for layer caching) | | 37 | +# Optional private index for Scale-internal packages such as sgp-obs, injected by the | | 38 | +# control-plane broker. Inert unless the secret is present, so local | | 39 | +# builds, CI builds, and agents that never opt in are unaffected. | | 40 | +# | | 41 | +# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting | | 42 | +# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. | 37 | 43 | RUN --mount=type=cache,target=/root/.cache/uv \ | | 44 | + --mount=type=secret,id=codeartifact-pip-conf,required=false \ | | 45 | + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ | | 46 | + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ | | 47 | + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ | | 48 | + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | | 49 | + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ | | 50 | + fi; \ | 38 | 51 | uv sync --no-install-project --no-dev | 39 | 52 |
| 40 | 53 | # Copy the project code | 41 | 54 | COPY {{ project_path_from_build_root }}/project ./project | 42 | 55 |
| 43 | 56 | # Install the project | 44 | 57 | RUN --mount=type=cache,target=/root/.cache/uv \ | | 58 | + --mount=type=secret,id=codeartifact-pip-conf,required=false \ | | 59 | + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ | | 60 | + export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \ | | 61 | + export UV_INDEX_SCALE_PYPI_USERNAME=aws; \ | | 62 | + export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \ | | 63 | + | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \ | | 64 | + fi; \ | 45 | 65 | uv sync --no-dev | 46 | 66 |
| 47 | 67 | ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/sync-gemini-cli/Dockerfile.j2 +13-2Lines changed: 13 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -20,7 +20,7 @@ RUN apt-get update && apt-get install -y \ | 20 | 20 | && apt-get clean \ | 21 | 21 | && rm -rf /var/lib/apt/lists/* | 22 | 22 |
| 23 | | -# Install the Gemini CLI CLI: the agent shells out to `gemini` on every turn, | | 23 | +# Install the Gemini CLI: the agent shells out to `gemini` on every turn, | 24 | 24 | # so the binary must be present in the runtime image. | 25 | 25 | RUN npm install -g @google/gemini-cli | 26 | 26 |
| @@ -33,8 +33,19 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr | 33 | 33 |
| 34 | 34 | WORKDIR /app/{{ project_path_from_build_root }} | 35 | 35 |
| | 36 | +# Optional private index for Scale-internal packages such as sgp-obs, injected by the | | 37 | +# control-plane broker. Inert unless the secret is present. | | 38 | +# | | 39 | +# This variant installs from requirements.txt, so there is no pyproject.toml for uv to | | 40 | +# read a named index out of; the credentialed URL is used directly and is deliberately | | 41 | +# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory. | | 42 | +# | 36 | 43 | # Install the required Python packages | 37 | | -RUN uv pip install --system -r requirements.txt | | 44 | +RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \ | | 45 | + if [ -s /run/secrets/codeartifact-pip-conf ]; then \ | | 46 | + export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \ | | 47 | + fi; \ | | 48 | + uv pip install --system -r requirements.txt | 38 | 49 |
| 39 | 50 | # Copy the project code | 40 | 51 | COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/sync-gemini-cli/README.md.j2 +2-2Lines changed: 2 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | # {{ agent_name }} - AgentEx Sync Gemini CLI Agent | 2 | 2 |
| 3 | | -This template builds a **synchronous** agent that drives the **Gemini CLI CLI** | | 3 | +This template builds a **synchronous** agent that drives the **Gemini CLI** | 4 | 4 | through the unified harness surface on AgentEx: | 5 | 5 | - Spawns `gemini -p "<prompt>" --output-format stream-json` as a local subprocess | 6 | 6 | - Wraps the CLI's stdout stream in a `GeminiCliTurn` | @@ -40,7 +40,7 @@ agentex agents run --manifest manifest.yaml | 40 | 40 |
| 41 | 41 | ### Sync ACP with the harness | 42 | 42 | The sync ACP model uses HTTP request/response. The `@acp.on_message_send` | 43 | | -handler spawns the Gemini CLI CLI and yields the harness events back to the | | 43 | +handler spawns the Gemini CLI and yields the harness events back to the | 44 | 44 | client as they arrive. | 45 | 45 |
| 46 | 46 | ### The unified harness surface | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/sync-gemini-cli/manifest.yaml.j2 +1-1Lines changed: 1 addition & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 75 | 75 | # Maps Kubernetes secrets to environment variables | 76 | 76 | # Common credentials include: | 77 | 77 | credentials: | 78 | | - # The Gemini CLI CLI authenticates with GEMINI_API_KEY (LITELLM_API_KEY | | 78 | + # The Gemini CLI authenticates with GEMINI_API_KEY (LITELLM_API_KEY | 79 | 79 | # is not read by the `gemini` subprocess this agent spawns). | 80 | 80 | - env_var_name: GEMINI_API_KEY | 81 | 81 | secret_name: gemini-api-key | |
Collapse file Copy file name to clipboardExpand all lines: src/agentex/lib/cli/templates/sync-gemini-cli/project/acp.py.j2 +17-1Lines changed: 17 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -35,6 +35,9 @@ from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_proce | 35 | 35 |
| 36 | 36 | logger = make_logger(__name__) | 37 | 37 |
| | 38 | +STDOUT_LINE_LIMIT = 8 * 1024 * 1024 | | 39 | +TERMINATE_TIMEOUT_SECONDS = 5.0 | | 40 | + | 38 | 41 | add_tracing_processor_config( | 39 | 42 | SGPTracingProcessorConfig( | 40 | 43 | sgp_api_key=os.environ.get("SGP_API_KEY", ""), | @@ -51,6 +54,11 @@ async def _spawn_gemini(prompt: str) -> AsyncIterator[str]: | 51 | 54 |
| 52 | 55 | This is a seam: tests can replace it with a fake async iterator of | 53 | 56 | pre-recorded lines so no real CLI invocation is needed offline. | | 57 | + | | 58 | + Lines up to ``STDOUT_LINE_LIMIT`` are read whole: a ``tool_result`` that | | 59 | + echoes a file is a single stream-json line, often past asyncio's 64 KiB | | 60 | + default. If the consumer stops early, the CLI gets SIGTERM and, after | | 61 | + ``TERMINATE_TIMEOUT_SECONDS``, SIGKILL, so cancellation cannot hang. | 54 | 62 | """ | 55 | 63 | # The prompt goes in via ``-p`` (argv). Stdin is closed on purpose: in | 56 | 64 | # non-interactive mode the CLI reads stdin to EOF and appends it to the | @@ -66,6 +74,7 @@ async def _spawn_gemini(prompt: str) -> AsyncIterator[str]: | 66 | 74 | stdin=asyncio.subprocess.DEVNULL, | 67 | 75 | stdout=asyncio.subprocess.PIPE, | 68 | 76 | stderr=asyncio.subprocess.PIPE, | | 77 | + limit=STDOUT_LINE_LIMIT, | 69 | 78 | ) | 70 | 79 | assert proc.stdout is not None | 71 | 80 |
| @@ -122,7 +131,14 @@ async def _spawn_gemini(prompt: str) -> AsyncIterator[str]: | 122 | 131 | proc.terminate() | 123 | 132 | except ProcessLookupError: | 124 | 133 | pass | 125 | | - await proc.wait() | | 134 | + try: | | 135 | + await asyncio.wait_for(proc.wait(), timeout=TERMINATE_TIMEOUT_SECONDS) | | 136 | + except TimeoutError: | | 137 | + try: | | 138 | + proc.kill() | | 139 | + except ProcessLookupError: | | 140 | + pass | | 141 | + await proc.wait() | 126 | 142 |
| 127 | 143 |
| 128 | 144 | @acp.on_message_send | |
|