agent-starter-pack 0.15.7__py3-none-any.whl → 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of agent-starter-pack might be problematic. Click here for more details.

Files changed (101) hide show
  1. {agent_starter_pack-0.15.7.dist-info → agent_starter_pack-0.16.0.dist-info}/METADATA +2 -2
  2. {agent_starter_pack-0.15.7.dist-info → agent_starter_pack-0.16.0.dist-info}/RECORD +96 -94
  3. agents/adk_base/.template/templateconfig.yaml +1 -1
  4. agents/{live_api → adk_live}/.template/templateconfig.yaml +5 -7
  5. agents/adk_live/README.md +31 -0
  6. agents/adk_live/app/agent.py +48 -0
  7. agents/adk_live/tests/unit/test_dummy.py +38 -0
  8. agents/agentic_rag/.template/templateconfig.yaml +1 -1
  9. agents/crewai_coding_crew/app/agent.py +18 -57
  10. agents/langgraph_base_react/app/agent.py +7 -46
  11. llm.txt +1 -1
  12. src/base_template/GEMINI.md +1 -1
  13. src/base_template/Makefile +130 -61
  14. src/base_template/README.md +6 -6
  15. src/base_template/deployment/terraform/dev/apis.tf +1 -1
  16. src/base_template/deployment/terraform/dev/variables.tf +1 -1
  17. src/base_template/deployment/terraform/locals.tf +1 -1
  18. src/base_template/deployment/terraform/variables.tf +1 -1
  19. src/base_template/pyproject.toml +22 -21
  20. src/base_template/{% if cookiecutter.cicd_runner == 'github_actions' %}.github{% else %}unused_github{% endif %}/workflows/deploy-to-prod.yaml +2 -2
  21. src/base_template/{% if cookiecutter.cicd_runner == 'github_actions' %}.github{% else %}unused_github{% endif %}/workflows/pr_checks.yaml +1 -1
  22. src/base_template/{% if cookiecutter.cicd_runner == 'github_actions' %}.github{% else %}unused_github{% endif %}/workflows/staging.yaml +71 -8
  23. src/base_template/{% if cookiecutter.cicd_runner == 'google_cloud_build' %}.cloudbuild{% else %}unused_.cloudbuild{% endif %}/deploy-to-prod.yaml +2 -2
  24. src/base_template/{% if cookiecutter.cicd_runner == 'google_cloud_build' %}.cloudbuild{% else %}unused_.cloudbuild{% endif %}/pr_checks.yaml +1 -1
  25. src/base_template/{% if cookiecutter.cicd_runner == 'google_cloud_build' %}.cloudbuild{% else %}unused_.cloudbuild{% endif %}/staging.yaml +90 -8
  26. src/base_template/{{cookiecutter.agent_directory}}/utils/tracing.py +1 -1
  27. src/base_template/{{cookiecutter.agent_directory}}/utils/typing.py +4 -4
  28. src/cli/utils/template.py +12 -5
  29. src/deployment_targets/agent_engine/tests/integration/test_agent_engine_app.py +205 -4
  30. src/deployment_targets/agent_engine/tests/load_test/README.md +47 -0
  31. src/deployment_targets/agent_engine/tests/load_test/load_test.py +132 -3
  32. src/deployment_targets/agent_engine/{{cookiecutter.agent_directory}}/agent_engine_app.py +11 -3
  33. src/deployment_targets/agent_engine/{{cookiecutter.agent_directory}}/utils/deployment.py +5 -1
  34. src/deployment_targets/agent_engine/{{cookiecutter.agent_directory}}/utils/{% if cookiecutter.is_adk_live %}expose_app.py{% else %}unused_expose_app.py{% endif %} +461 -0
  35. src/deployment_targets/cloud_run/Dockerfile +3 -3
  36. src/deployment_targets/cloud_run/deployment/terraform/dev/service.tf +4 -4
  37. src/deployment_targets/cloud_run/deployment/terraform/service.tf +7 -7
  38. src/deployment_targets/cloud_run/tests/integration/test_server_e2e.py +207 -5
  39. src/deployment_targets/cloud_run/tests/load_test/README.md +82 -0
  40. src/deployment_targets/cloud_run/tests/load_test/load_test.py +130 -3
  41. src/deployment_targets/cloud_run/{{cookiecutter.agent_directory}}/server.py +178 -146
  42. src/frontends/{live_api_react → adk_live_react}/frontend/package-lock.json +39 -1007
  43. src/frontends/{live_api_react → adk_live_react}/frontend/package.json +1 -9
  44. src/frontends/{live_api_react → adk_live_react}/frontend/src/App.tsx +1 -1
  45. src/frontends/{live_api_react → adk_live_react}/frontend/src/components/logger/Logger.tsx +8 -3
  46. src/frontends/{live_api_react → adk_live_react}/frontend/src/components/logger/logger.scss +26 -0
  47. src/frontends/{live_api_react → adk_live_react}/frontend/src/components/side-panel/SidePanel.tsx +11 -5
  48. src/frontends/{live_api_react → adk_live_react}/frontend/src/components/side-panel/side-panel.scss +146 -115
  49. src/frontends/adk_live_react/frontend/src/components/transcription-preview/TranscriptionPreview.tsx +106 -0
  50. src/frontends/adk_live_react/frontend/src/components/transcription-preview/transcription-preview.scss +150 -0
  51. src/frontends/{live_api_react → adk_live_react}/frontend/src/hooks/use-live-api.ts +8 -2
  52. src/frontends/{live_api_react → adk_live_react}/frontend/src/multimodal-live-types.ts +38 -2
  53. src/frontends/{live_api_react → adk_live_react}/frontend/src/utils/audio-recorder.ts +1 -1
  54. src/frontends/{live_api_react → adk_live_react}/frontend/src/utils/audio-streamer.ts +1 -1
  55. src/frontends/{live_api_react → adk_live_react}/frontend/src/utils/multimodal-live-client.ts +204 -23
  56. src/frontends/{live_api_react → adk_live_react}/frontend/src/utils/utils.ts +27 -5
  57. src/frontends/streamlit/frontend/utils/local_chat_history.py +2 -0
  58. src/resources/idx/.idx/dev.nix +25 -11
  59. src/resources/idx/idx-template.json +1 -16
  60. src/resources/idx/idx-template.nix +2 -3
  61. src/resources/locks/uv-adk_base-agent_engine.lock +434 -349
  62. src/resources/locks/uv-adk_base-cloud_run.lock +502 -409
  63. src/resources/locks/uv-adk_live-agent_engine.lock +4189 -0
  64. src/resources/locks/{uv-live_api-cloud_run.lock → uv-adk_live-cloud_run.lock} +884 -2219
  65. src/resources/locks/uv-agentic_rag-agent_engine.lock +473 -388
  66. src/resources/locks/uv-agentic_rag-cloud_run.lock +557 -464
  67. src/resources/locks/uv-crewai_coding_crew-agent_engine.lock +498 -515
  68. src/resources/locks/uv-crewai_coding_crew-cloud_run.lock +898 -687
  69. src/resources/locks/uv-langgraph_base_react-agent_engine.lock +455 -483
  70. src/resources/locks/uv-langgraph_base_react-cloud_run.lock +910 -645
  71. src/utils/generate_locks.py +8 -4
  72. agents/live_api/README.md +0 -37
  73. agents/live_api/app/agent.py +0 -72
  74. agents/live_api/tests/integration/test_server_e2e.py +0 -260
  75. agents/live_api/tests/load_test/load_test.py +0 -40
  76. agents/live_api/tests/unit/test_server.py +0 -144
  77. {agent_starter_pack-0.15.7.dist-info → agent_starter_pack-0.16.0.dist-info}/WHEEL +0 -0
  78. {agent_starter_pack-0.15.7.dist-info → agent_starter_pack-0.16.0.dist-info}/entry_points.txt +0 -0
  79. {agent_starter_pack-0.15.7.dist-info → agent_starter_pack-0.16.0.dist-info}/licenses/LICENSE +0 -0
  80. /src/frontends/{live_api_react → adk_live_react}/frontend/public/favicon.ico +0 -0
  81. /src/frontends/{live_api_react → adk_live_react}/frontend/public/index.html +0 -0
  82. /src/frontends/{live_api_react → adk_live_react}/frontend/public/robots.txt +0 -0
  83. /src/frontends/{live_api_react → adk_live_react}/frontend/src/App.scss +0 -0
  84. /src/frontends/{live_api_react → adk_live_react}/frontend/src/App.test.tsx +0 -0
  85. /src/frontends/{live_api_react → adk_live_react}/frontend/src/components/audio-pulse/AudioPulse.tsx +0 -0
  86. /src/frontends/{live_api_react → adk_live_react}/frontend/src/components/audio-pulse/audio-pulse.scss +0 -0
  87. /src/frontends/{live_api_react → adk_live_react}/frontend/src/components/logger/mock-logs.ts +0 -0
  88. /src/frontends/{live_api_react → adk_live_react}/frontend/src/contexts/LiveAPIContext.tsx +0 -0
  89. /src/frontends/{live_api_react → adk_live_react}/frontend/src/hooks/use-media-stream-mux.ts +0 -0
  90. /src/frontends/{live_api_react → adk_live_react}/frontend/src/hooks/use-screen-capture.ts +0 -0
  91. /src/frontends/{live_api_react → adk_live_react}/frontend/src/hooks/use-webcam.ts +0 -0
  92. /src/frontends/{live_api_react → adk_live_react}/frontend/src/index.css +0 -0
  93. /src/frontends/{live_api_react → adk_live_react}/frontend/src/index.tsx +0 -0
  94. /src/frontends/{live_api_react → adk_live_react}/frontend/src/react-app-env.d.ts +0 -0
  95. /src/frontends/{live_api_react → adk_live_react}/frontend/src/reportWebVitals.ts +0 -0
  96. /src/frontends/{live_api_react → adk_live_react}/frontend/src/setupTests.ts +0 -0
  97. /src/frontends/{live_api_react → adk_live_react}/frontend/src/utils/audioworklet-registry.ts +0 -0
  98. /src/frontends/{live_api_react → adk_live_react}/frontend/src/utils/store-logger.ts +0 -0
  99. /src/frontends/{live_api_react → adk_live_react}/frontend/src/utils/worklets/audio-processing.ts +0 -0
  100. /src/frontends/{live_api_react → adk_live_react}/frontend/src/utils/worklets/vol-meter.ts +0 -0
  101. /src/frontends/{live_api_react → adk_live_react}/frontend/tsconfig.json +0 -0
@@ -11,6 +11,134 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
+ {%- if cookiecutter.agent_name == "adk_live" %}
15
+
16
+ import json
17
+ import logging
18
+ import time
19
+
20
+ from locust import User, between, task
21
+ from websockets.exceptions import WebSocketException
22
+ from websockets.sync.client import connect
23
+
24
+ logging.basicConfig(level=logging.INFO)
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class WebSocketUser(User):
29
+ """Simulates a user making websocket requests to the remote agent engine."""
30
+
31
+ wait_time = between(1, 3) # Wait 1-3 seconds between tasks
32
+ abstract = True
33
+
34
+ def __init__(self, *args: object, **kwargs: object) -> None:
35
+ super().__init__(*args, **kwargs)
36
+ if self.host.startswith("https://"):
37
+ self.ws_url = self.host.replace("https://", "wss://", 1) + "/ws"
38
+ elif self.host.startswith("http://"):
39
+ self.ws_url = self.host.replace("http://", "ws://", 1) + "/ws"
40
+ else:
41
+ self.ws_url = self.host + "/ws"
42
+
43
+ @task
44
+ def websocket_audio_conversation(self) -> None:
45
+ """Test a full websocket conversation with audio input."""
46
+ start_time = time.time()
47
+ response_count = 0
48
+ exception = None
49
+
50
+ try:
51
+ response_count = self._websocket_interaction()
52
+
53
+ # Mark as failure if we got no valid responses
54
+ if response_count == 0:
55
+ exception = Exception("No responses received from agent")
56
+
57
+ except WebSocketException as e:
58
+ exception = e
59
+ logger.error(f"WebSocket error: {e}")
60
+ except Exception as e:
61
+ exception = e
62
+ logger.error(f"Unexpected error: {e}")
63
+ finally:
64
+ total_time = int((time.time() - start_time) * 1000)
65
+
66
+ # Report the request metrics to Locust
67
+ self.environment.events.request.fire(
68
+ request_type="WS",
69
+ name="websocket_conversation",
70
+ response_time=total_time,
71
+ response_length=response_count * 100, # Approximate response size
72
+ response=None,
73
+ context={},
74
+ exception=exception,
75
+ )
76
+
77
+ def _websocket_interaction(self) -> int:
78
+ """Handle the websocket interaction and return response count."""
79
+ response_count = 0
80
+
81
+ with connect(self.ws_url, open_timeout=10, close_timeout=20) as websocket:
82
+ # Wait for setupComplete
83
+ setup_response = websocket.recv(timeout=10.0)
84
+ setup_data = json.loads(setup_response)
85
+ assert "setupComplete" in setup_data, (
86
+ f"Expected setupComplete, got {setup_data}"
87
+ )
88
+ logger.info("Received setupComplete")
89
+
90
+ # Send dummy audio chunk with user_id
91
+ dummy_audio = bytes([0] * 1024)
92
+ audio_msg = {
93
+ "user_id": "load-test-user",
94
+ "realtimeInput": {
95
+ "mediaChunks": [
96
+ {
97
+ "mimeType": "audio/pcm;rate=16000",
98
+ "data": dummy_audio.hex(),
99
+ }
100
+ ]
101
+ },
102
+ }
103
+ websocket.send(json.dumps(audio_msg))
104
+ logger.info("Sent audio chunk")
105
+
106
+ # Send text message to complete the turn
107
+ text_msg = {
108
+ "content": {
109
+ "role": "user",
110
+ "parts": [{"text": "Hello!"}],
111
+ }
112
+ }
113
+ websocket.send(json.dumps(text_msg))
114
+ logger.info("Sent text completion")
115
+
116
+ # Collect responses until turn_complete or timeout
117
+ for _ in range(20): # Max 20 responses
118
+ try:
119
+ response = websocket.recv(timeout=10.0)
120
+ response_data = json.loads(response)
121
+ response_count += 1
122
+ logger.debug(f"Received response: {response_data}")
123
+
124
+ if isinstance(response_data, dict) and response_data.get(
125
+ "turn_complete"
126
+ ):
127
+ logger.info(f"Turn complete after {response_count} responses")
128
+ break
129
+ except TimeoutError:
130
+ logger.info(f"Timeout after {response_count} responses")
131
+ break
132
+
133
+ return response_count
134
+
135
+
136
+ class RemoteAgentUser(WebSocketUser):
137
+ """User for testing remote agent engine deployment."""
138
+
139
+ # Set the host via command line: locust -f load_test.py --host=https://your-deployed-service.run.app
140
+ host = "http://localhost:8000" # Default for local testing
141
+ {%- else %}
14
142
 
15
143
  import json
16
144
  import logging
@@ -54,7 +182,7 @@ class ChatStreamUser(HttpUser):
54
182
  """Simulates a chat stream interaction."""
55
183
  headers = {"Content-Type": "application/json"}
56
184
  headers["Authorization"] = f"Bearer {os.environ['_AUTH_TOKEN']}"
57
- {% if "adk" in cookiecutter.tags %}
185
+ {% if cookiecutter.is_adk %}
58
186
  data = {
59
187
  "class_method": "async_stream_query",
60
188
  "input": {
@@ -84,7 +212,7 @@ class ChatStreamUser(HttpUser):
84
212
  headers=headers,
85
213
  json=data,
86
214
  catch_response=True,
87
- {%- if "adk" in cookiecutter.tags %}
215
+ {%- if cookiecutter.is_adk %}
88
216
  name="/streamQuery async_stream_query",
89
217
  {%- else %}
90
218
  name="/stream_messages first message",
@@ -112,7 +240,7 @@ class ChatStreamUser(HttpUser):
112
240
  total_time = end_time - start_time
113
241
  self.environment.events.request.fire(
114
242
  request_type="POST",
115
- {%- if "adk" in cookiecutter.tags %}
243
+ {%- if cookiecutter.is_adk %}
116
244
  name="/streamQuery end",
117
245
  {%- else %}
118
246
  name="/stream_messages end",
@@ -124,3 +252,4 @@ class ChatStreamUser(HttpUser):
124
252
  )
125
253
  else:
126
254
  response.failure(f"Unexpected status code: {response.status_code}")
255
+ {%- endif %}
@@ -13,7 +13,7 @@
13
13
  # limitations under the License.
14
14
 
15
15
  # mypy: disable-error-code="attr-defined,arg-type"
16
- {%- if "adk" in cookiecutter.tags %}
16
+ {%- if cookiecutter.is_adk %}
17
17
  import logging
18
18
  import os
19
19
  from typing import Any
@@ -25,8 +25,12 @@ from google.adk.artifacts import GcsArtifactService
25
25
  from google.cloud import logging as google_cloud_logging
26
26
  from opentelemetry import trace
27
27
  from opentelemetry.sdk.trace import TracerProvider, export
28
- from vertexai._genai.types import AgentEngine, AgentEngineConfig
28
+ from vertexai._genai.types import AgentEngine, AgentEngineConfig{%- if cookiecutter.is_adk_live %}, AgentServerMode{%- endif %}
29
+ {%- if cookiecutter.is_adk_live %}
30
+ from vertexai.preview.reasoning_engines import AdkApp
31
+ {%- else %}
29
32
  from vertexai.agent_engines.templates.adk import AdkApp
33
+ {%- endif %}
30
34
 
31
35
  from {{cookiecutter.agent_directory}}.agent import root_agent
32
36
  from {{cookiecutter.agent_directory}}.utils.deployment import (
@@ -282,7 +286,7 @@ def deploy_agent_engine_app(
282
286
  # Read requirements
283
287
  with open(requirements_file) as f:
284
288
  requirements = f.read().strip().split("\n")
285
- {% if "adk" in cookiecutter.tags %}
289
+ {% if cookiecutter.is_adk %}
286
290
  agent_engine = AgentEngineApp(
287
291
  agent=root_agent,
288
292
  artifact_service_builder=lambda: GcsArtifactService(
@@ -314,6 +318,10 @@ def deploy_agent_engine_app(
314
318
  requirements=requirements,
315
319
  staging_bucket=staging_bucket_uri,
316
320
  labels=labels,
321
+ {%- if cookiecutter.is_adk_live %}
322
+ agent_server_mode=AgentServerMode.EXPERIMENTAL, # Enable bidi streaming
323
+ resource_limits={"cpu": "4", "memory": "8Gi"},
324
+ {%- endif %}
317
325
  )
318
326
 
319
327
  agent_config = {
@@ -75,9 +75,13 @@ def print_deployment_success(
75
75
  agent_engine_id = remote_agent.api_resource.name.split("/")[-1]
76
76
  console_url = f"https://console.cloud.google.com/vertex-ai/agents/locations/{location}/agent-engines/{agent_engine_id}?project={project}"
77
77
 
78
- {%- if "adk" in cookiecutter.tags %}
78
+ {%- if cookiecutter.is_adk %}
79
79
  print(
80
+ {%- if cookiecutter.is_adk_live %}
81
+ f"\n✅ Deployment successful! Run your agent with: `make playground-remote`"
82
+ {%- else %}
80
83
  f"\n✅ Deployment successful! Test your agent: notebooks/adk_app_testing.ipynb"
84
+ {%- endif %}
81
85
  f"\n📊 View in console: {console_url}\n"
82
86
  )
83
87
  {%- else %}
@@ -0,0 +1,461 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Literal
21
+
22
+ import backoff
23
+ import google.auth
24
+ import vertexai
25
+ from fastapi import FastAPI, HTTPException, WebSocket
26
+ from fastapi.middleware.cors import CORSMiddleware
27
+ from fastapi.responses import FileResponse
28
+ from fastapi.staticfiles import StaticFiles
29
+ from google.cloud import logging as google_cloud_logging
30
+ from pydantic import BaseModel
31
+ from websockets.exceptions import ConnectionClosedError
32
+
33
+ from ..agent import root_agent
34
+ from ..agent_engine_app import AgentEngineApp
35
+
36
+ app = FastAPI()
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["*"],
40
+ allow_methods=["*"],
41
+ allow_headers=["*"],
42
+ )
43
+
44
+ # Get the path to the frontend build directory
45
+ current_dir = Path(__file__).parent
46
+ frontend_build_dir = current_dir.parent.parent / "frontend" / "build"
47
+
48
+ # Mount static files if build directory exists
49
+ if frontend_build_dir.exists():
50
+ app.mount(
51
+ "/static",
52
+ StaticFiles(directory=str(frontend_build_dir / "static")),
53
+ name="static",
54
+ )
55
+ logging_client = google_cloud_logging.Client()
56
+ logger = logging_client.logger(__name__)
57
+ logging.basicConfig(level=logging.INFO)
58
+
59
+ # Global configuration for agent engine mode
60
+ USE_REMOTE_AGENT = False
61
+ REMOTE_AGENT_ENGINE_ID = None
62
+ PROJECT_ID = None
63
+ LOCATION = "us-central1"
64
+ LOCAL_AGENT_CALLABLE = None
65
+
66
+
67
+ class WebSocketToQueueAdapter:
68
+ """Adapter to convert WebSocket messages to an asyncio Queue for the agent engine."""
69
+
70
+ def __init__(
71
+ self, websocket: WebSocket, agent_engine: AgentEngineApp | None = None
72
+ ):
73
+ """Initialize the adapter.
74
+
75
+ Args:
76
+ websocket: The client websocket connection
77
+ agent_engine: The agent engine instance (None if using remote)
78
+ """
79
+ self.websocket = websocket
80
+ self.agent_engine = agent_engine
81
+ self.input_queue: asyncio.Queue[dict] = asyncio.Queue()
82
+ self.first_message = True
83
+
84
+ def _transform_remote_agent_engine_response(self, response: dict) -> dict:
85
+ """Transform remote Agent Engine bidiStreamOutput to ADK Event format for frontend."""
86
+ # Check if this is a remote Agent Engine bidiStreamOutput
87
+ bidi_output = response.get("bidiStreamOutput")
88
+ if not bidi_output:
89
+ # Not a remote agent engine response, return as-is
90
+ return response
91
+
92
+ # Transform to ADK Event format that frontend already handles
93
+ # Just unwrap the bidiStreamOutput wrapper - the content is already in ADK Event format
94
+ return bidi_output
95
+
96
+ async def receive_from_client(self) -> None:
97
+ """Listen for messages from the client and put them in the queue."""
98
+ while True:
99
+ try:
100
+ # Use receive() instead of receive_json() to handle both text and binary data
101
+ message = await self.websocket.receive()
102
+
103
+ # Handle different message types
104
+ if "text" in message:
105
+ # Parse JSON text messages
106
+ data = json.loads(message["text"])
107
+
108
+ if isinstance(data, dict):
109
+ # Skip setup messages - they're for backend logging only, not valid LiveRequest format
110
+ if "setup" in data:
111
+ # Log setup information
112
+ logger.log_struct(
113
+ {**data["setup"], "type": "setup"}, severity="INFO"
114
+ )
115
+ logging.info(
116
+ "Received setup message (not forwarding to agent)"
117
+ )
118
+ continue
119
+
120
+ # Frontend handles message format for both modes
121
+ await self.input_queue.put(data)
122
+ else:
123
+ logging.warning(
124
+ f"Received unexpected JSON structure from client: {data}"
125
+ )
126
+
127
+ elif "bytes" in message:
128
+ # Handle binary data
129
+ # Convert binary to appropriate format for agent engine
130
+ await self.input_queue.put({"binary_data": message["bytes"]})
131
+
132
+ else:
133
+ logging.warning(
134
+ f"Received unexpected message type from client: {message}"
135
+ )
136
+
137
+ except ConnectionClosedError as e:
138
+ logging.warning(f"Client closed connection: {e}")
139
+ break
140
+ except json.JSONDecodeError as e:
141
+ logging.error(f"Error parsing JSON from client: {e}")
142
+ break
143
+ except Exception as e:
144
+ logging.error(f"Error receiving from client: {e!s}")
145
+ break
146
+
147
+ async def run_agent_engine(self) -> None:
148
+ """Run the agent engine with the input queue."""
149
+ try:
150
+ if self.agent_engine is not None:
151
+ # Local agent engine mode
152
+ # Give the agent engine a moment to initialize before sending setupComplete
153
+ await asyncio.sleep(1)
154
+
155
+ # Send setupComplete after initialization delay
156
+ setup_complete_response: dict = {"setupComplete": {}}
157
+ await self.websocket.send_json(setup_complete_response)
158
+
159
+ async for response in self.agent_engine.bidi_stream_query(
160
+ self.input_queue
161
+ ):
162
+ # Send responses from agent engine to the websocket client
163
+ if response is not None:
164
+ await self.websocket.send_json(response)
165
+
166
+ # Check for error responses
167
+ if isinstance(response, dict) and "error" in response:
168
+ logging.error(f"Agent engine error: {response['error']}")
169
+ break
170
+ else:
171
+ # Remote agent engine mode
172
+ # Don't send setupComplete until remote connection is established
173
+ await self.run_remote_agent_engine()
174
+ except Exception as e:
175
+ logging.error(f"Error in agent engine: {e}")
176
+ await self.websocket.send_json({"error": str(e)})
177
+
178
+ async def run_remote_agent_engine(self) -> None:
179
+ """Run the remote agent engine connection."""
180
+ client = vertexai.Client(
181
+ project=PROJECT_ID,
182
+ location=LOCATION,
183
+ )
184
+
185
+ async with client.aio.live.agent_engines.connect(
186
+ agent_engine=REMOTE_AGENT_ENGINE_ID,
187
+ config={"class_method": "bidi_stream_query"},
188
+ ) as session:
189
+ # Send setupComplete only after remote connection is established
190
+ logging.info("Remote agent engine connection established")
191
+ setup_complete_response: dict = {"setupComplete": {}}
192
+ await self.websocket.send_json(setup_complete_response)
193
+
194
+ # Create task to forward messages from queue to remote session
195
+ async def forward_to_remote() -> None:
196
+ while True:
197
+ try:
198
+ message = await self.input_queue.get()
199
+ await session.send(message)
200
+ except Exception as e:
201
+ logging.error(f"Error forwarding to remote: {e}")
202
+ break
203
+
204
+ # Create task to receive from remote and send to websocket
205
+ async def receive_from_remote() -> None:
206
+ while True:
207
+ try:
208
+ response = await session.receive()
209
+ if response is not None:
210
+ # Transform remote Agent Engine bidiStreamOutput format to frontend format
211
+ transformed = self._transform_remote_agent_engine_response(
212
+ response
213
+ )
214
+ if transformed:
215
+ await self.websocket.send_json(transformed)
216
+
217
+ # Check for error responses
218
+ if isinstance(response, dict) and "error" in response:
219
+ logging.error(
220
+ f"Remote agent engine error: {response['error']}"
221
+ )
222
+ break
223
+ except Exception as e:
224
+ logging.error(f"Error receiving from remote: {e}")
225
+ break
226
+
227
+ await asyncio.gather(
228
+ forward_to_remote(),
229
+ receive_from_remote(),
230
+ )
231
+
232
+
233
+ def get_connect_and_run_callable(websocket: WebSocket) -> Callable:
234
+ """Create a callable that handles agent engine connection with retry logic.
235
+
236
+ Args:
237
+ websocket: The client websocket connection
238
+
239
+ Returns:
240
+ Callable: An async function that establishes and manages the agent engine connection
241
+ """
242
+
243
+ async def on_backoff(details: backoff._typing.Details) -> None:
244
+ await websocket.send_json(
245
+ {
246
+ "status": f"Model connection error, retrying in {details['wait']} seconds..."
247
+ }
248
+ )
249
+
250
+ @backoff.on_exception(
251
+ backoff.expo, ConnectionClosedError, max_tries=10, on_backoff=on_backoff
252
+ )
253
+ async def connect_and_run() -> None:
254
+ if USE_REMOTE_AGENT:
255
+ # Remote agent engine mode
256
+ logging.info(f"Connecting to remote agent engine: {REMOTE_AGENT_ENGINE_ID}")
257
+ adapter = WebSocketToQueueAdapter(websocket, agent_engine=None)
258
+ else:
259
+ # Local agent engine mode
260
+ agent_callable = (
261
+ LOCAL_AGENT_CALLABLE if LOCAL_AGENT_CALLABLE is not None else root_agent
262
+ )
263
+ logging.info(
264
+ f"Starting local agent engine with callable: {agent_callable.__name__ if hasattr(agent_callable, '__name__') else agent_callable}"
265
+ )
266
+ agent_engine = AgentEngineApp(agent=agent_callable)
267
+ adapter = WebSocketToQueueAdapter(websocket, agent_engine)
268
+
269
+ logging.info("Starting bidirectional communication with agent engine")
270
+ await asyncio.gather(
271
+ adapter.receive_from_client(),
272
+ adapter.run_agent_engine(),
273
+ )
274
+
275
+ return connect_and_run
276
+
277
+
278
+ @app.websocket("/ws")
279
+ async def websocket_endpoint(websocket: WebSocket) -> None:
280
+ """Handle new websocket connections."""
281
+ await websocket.accept()
282
+ connect_and_run = get_connect_and_run_callable(websocket)
283
+ await connect_and_run()
284
+
285
+
286
+ class Feedback(BaseModel):
287
+ """Represents feedback for a conversation."""
288
+
289
+ score: int | float
290
+ text: str | None = ""
291
+ run_id: str
292
+ user_id: str | None
293
+ log_type: Literal["feedback"] = "feedback"
294
+
295
+
296
+ @app.post("/feedback")
297
+ def collect_feedback(feedback: Feedback) -> dict[str, str]:
298
+ """Collect and log feedback.
299
+
300
+ Args:
301
+ feedback: The feedback data to log
302
+
303
+ Returns:
304
+ Success message
305
+ """
306
+ logger.log_struct(feedback.model_dump(), severity="INFO")
307
+ return {"status": "success"}
308
+
309
+
310
+ @app.get("/")
311
+ async def serve_frontend_root() -> FileResponse:
312
+ """Serve the frontend index.html at the root path."""
313
+ index_file = frontend_build_dir / "index.html"
314
+ if index_file.exists():
315
+ return FileResponse(str(index_file))
316
+ raise HTTPException(
317
+ status_code=404,
318
+ detail="Frontend not built. Run 'npm run build' in the frontend directory.",
319
+ )
320
+
321
+
322
+ @app.get("/{full_path:path}")
323
+ async def serve_frontend_spa(full_path: str) -> FileResponse:
324
+ """Catch-all route to serve the frontend for SPA routing.
325
+
326
+ This ensures that client-side routes are handled by the React app.
327
+ Excludes API routes (ws, feedback) and static assets.
328
+ """
329
+ # Don't intercept API routes
330
+ if full_path.startswith(("ws", "feedback", "static", "api")):
331
+ raise HTTPException(status_code=404, detail="Not found")
332
+
333
+ # Serve index.html for all other routes (SPA routing)
334
+ index_file = frontend_build_dir / "index.html"
335
+ if index_file.exists():
336
+ return FileResponse(str(index_file))
337
+ raise HTTPException(
338
+ status_code=404,
339
+ detail="Frontend not built. Run 'npm run build' in the frontend directory.",
340
+ )
341
+
342
+
343
+ # Main execution
344
+ if __name__ == "__main__":
345
+ import argparse
346
+
347
+ import uvicorn
348
+
349
+ parser = argparse.ArgumentParser(description="Agent Engine Proxy Server")
350
+ parser.add_argument(
351
+ "--mode",
352
+ choices=["local", "remote"],
353
+ default="local",
354
+ help="Agent engine mode: 'local' for local agent or 'remote' for deployed agent engine",
355
+ )
356
+ parser.add_argument(
357
+ "--remote-id",
358
+ type=str,
359
+ help="Remote agent engine ID (required when mode=remote)",
360
+ )
361
+ parser.add_argument(
362
+ "--project-id", type=str, help="GCP project ID (required when mode=remote)"
363
+ )
364
+ parser.add_argument(
365
+ "--location",
366
+ type=str,
367
+ default="us-central1",
368
+ help="GCP location (default: us-central1)",
369
+ )
370
+ parser.add_argument(
371
+ "--local-agent",
372
+ type=str,
373
+ help="Python path to local agent callable (e.g., 'app.agent.root_agent'). Defaults to root_agent",
374
+ )
375
+ parser.add_argument(
376
+ "--port",
377
+ type=int,
378
+ default=8000,
379
+ help="Port to run the server on (default: 8000)",
380
+ )
381
+ parser.add_argument(
382
+ "--host",
383
+ type=str,
384
+ default="localhost",
385
+ help="Host to run the server on (default: localhost)",
386
+ )
387
+
388
+ args = parser.parse_args()
389
+
390
+ if args.mode == "remote":
391
+ USE_REMOTE_AGENT = True
392
+
393
+ # Try to load from deployment_metadata.json if remote-id not provided
394
+ if not args.remote_id:
395
+ deployment_metadata_path = (
396
+ Path(__file__).parent.parent.parent / "deployment_metadata.json"
397
+ )
398
+ if deployment_metadata_path.exists():
399
+ with open(deployment_metadata_path) as f:
400
+ metadata = json.load(f)
401
+ REMOTE_AGENT_ENGINE_ID = metadata.get("remote_agent_engine_id")
402
+ if not REMOTE_AGENT_ENGINE_ID:
403
+ parser.error(
404
+ "No remote_agent_engine_id found in deployment_metadata.json"
405
+ )
406
+ print("Loaded remote agent engine ID from deployment_metadata.json")
407
+ else:
408
+ parser.error(
409
+ "--remote-id is required when deployment_metadata.json is not found"
410
+ )
411
+ else:
412
+ REMOTE_AGENT_ENGINE_ID = args.remote_id
413
+
414
+ # Extract project ID from remote agent engine ID if not provided
415
+ if not args.project_id:
416
+ # Format: projects/PROJECT_ID/locations/LOCATION/reasoningEngines/ENGINE_ID
417
+ import re
418
+
419
+ match = re.match(
420
+ r"projects/([^/]+)/locations/([^/]+)/reasoningEngines/",
421
+ REMOTE_AGENT_ENGINE_ID,
422
+ )
423
+ if match:
424
+ PROJECT_ID = match.group(1)
425
+ extracted_location = match.group(2)
426
+ LOCATION = (
427
+ args.location
428
+ if args.location != "us-central1"
429
+ else extracted_location
430
+ )
431
+ print("Extracted project ID and location from remote agent engine ID")
432
+ else:
433
+ # Fall back to google.auth.default()
434
+ try:
435
+ _, PROJECT_ID = google.auth.default()
436
+ LOCATION = args.location
437
+ print(f"Using default project ID from google.auth: {PROJECT_ID}")
438
+ except Exception as e:
439
+ parser.error(f"Could not determine project ID: {e}")
440
+ else:
441
+ PROJECT_ID = args.project_id
442
+ LOCATION = args.location
443
+
444
+ print("Starting server in REMOTE mode:")
445
+ print(f" Remote Agent Engine ID: {REMOTE_AGENT_ENGINE_ID}")
446
+ print(f" Project ID: {PROJECT_ID}")
447
+ print(f" Location: {LOCATION}")
448
+ else:
449
+ print("Starting server in LOCAL mode")
450
+ if args.local_agent:
451
+ # Dynamically import the agent callable
452
+ import importlib
453
+
454
+ module_path, callable_name = args.local_agent.rsplit(".", 1)
455
+ module = importlib.import_module(module_path)
456
+ LOCAL_AGENT_CALLABLE = getattr(module, callable_name)
457
+ print(f" Using custom agent: {args.local_agent}")
458
+ else:
459
+ print(" Using default agent: root_agent")
460
+
461
+ uvicorn.run(app, host=args.host, port=args.port)