You've already forked adk-python
mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f94a0c7b3 | |||
| 4f48ea1c08 | |||
| 3fddac5813 | |||
| 9b52ce28f5 | |||
| e8fb4ed3fc | |||
| 42f87a9c94 | |||
| d387ab0c38 | |||
| f12300113d | |||
| 6c0a0d69ca | |||
| 23b41c3f94 | |||
| 14933ba470 | |||
| b691904e57 | |||
| 641781bced | |||
| c4e09a2edb | |||
| acbbdb7266 | |||
| 27ce65ff50 | |||
| 926b0ef1a6 | |||
| dbbeb190b0 | |||
| 2a9ddec7e3 | |||
| 2ea4315e9f | |||
| 67b6fbbe01 | |||
| fcbf57466e | |||
| 504aa6ba06 | |||
| 08ac9a117e | |||
| 4ae8d72a8d | |||
| 21736067f9 | |||
| 9a9f7a3765 | |||
| aa7a98ad9d | |||
| 27b214df3e |
@@ -0,0 +1,59 @@
|
||||
name: Check Pyink Formatting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**/*.py'
|
||||
- 'tests/**/*.py'
|
||||
- 'pyproject.toml'
|
||||
|
||||
jobs:
|
||||
pyink-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install pyink
|
||||
run: |
|
||||
pip install pyink
|
||||
|
||||
- name: Detect changed Python files
|
||||
id: detect_changes
|
||||
run: |
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
CHANGED_FILES=$(git diff --diff-filter=ACMR --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.py$' || true)
|
||||
echo "CHANGED_FILES=${CHANGED_FILES}" >> $GITHUB_ENV
|
||||
|
||||
- name: Run pyink on changed files
|
||||
if: env.CHANGED_FILES != ''
|
||||
run: |
|
||||
echo "Changed Python files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Run pyink --check
|
||||
set +e
|
||||
pyink --check --config pyproject.toml $CHANGED_FILES
|
||||
RESULT=$?
|
||||
set -e
|
||||
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Pyink formatting check failed!"
|
||||
echo "👉 To fix formatting, run locally:"
|
||||
echo ""
|
||||
echo " pyink --config pyproject.toml $CHANGED_FILES"
|
||||
echo ""
|
||||
exit $RESULT
|
||||
fi
|
||||
|
||||
- name: No changed Python files detected
|
||||
if: env.CHANGED_FILES == ''
|
||||
run: |
|
||||
echo "No Python files changed. Skipping pyink check."
|
||||
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### âš BREAKING CHANGES
|
||||
* Set the max size of strings in database columns. MySQL mandates that all VARCHAR-type fields must specify their lengths.
|
||||
* Extract content encode/decode logic to a shared util, resolve issues with JSON serialization, and update key length for DB table to avoid key too long issue in mysql.
|
||||
* Enhance `FunctionTool` to verify if the model is providing all the mandatory arguments.
|
||||
|
||||
### Features
|
||||
* Update ADK setup guide to improve onboarding experience.
|
||||
* feat: add ordering to recent events in database session service.
|
||||
* feat(llm_flows): support async before/after tool callbacks.
|
||||
* feat: Added --replay and --resume options to adk run cli. Check adk run --help for more details.
|
||||
* Created a new Integration Connector Tool (underlying of the ApplicationIntegrationToolSet) so that we do not force LLM to provide default value.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Don't send content with empty text to LLM.
|
||||
* Fix google search reading undefined for `renderedContent`.
|
||||
|
||||
### Miscellaneous Chores
|
||||
* Docstring improvements, typo fixings, github action to enfore code styles on formatting and imports, etc.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### âš BREAKING CHANGES
|
||||
|
||||
@@ -18,11 +18,7 @@
|
||||
</h3>
|
||||
</html>
|
||||
|
||||
Agent Development Kit (ADK) is designed for developers seeking fine-grained
|
||||
control and flexibility when building advanced AI agents that are tightly
|
||||
integrated with services in Google Cloud. It allows you to define agent
|
||||
behavior, orchestration, and tool use directly in code, enabling robust
|
||||
debugging, versioning, and deployment anywhere – from your laptop to the cloud.
|
||||
Agent Development Kit (ADK) is a flexible and modular framework for developing and deploying AI agents. While optimized for Gemini and the Google ecosystem, ADK is model-agnostic, deployment-agnostic, and is built for compatibility with other frameworks. ADK was designed to make agent development feel more like software development, to make it easier for developers to create, deploy, and orchestrate agentic architectures that range from simple tasks to complex workflows.
|
||||
|
||||
|
||||
---
|
||||
|
||||
+12
-8
@@ -119,6 +119,15 @@ line-length = 80
|
||||
unstable = true
|
||||
pyink-indentation = 2
|
||||
pyink-use-majority-quotes = true
|
||||
pyink-annotation-pragmas = [
|
||||
"noqa",
|
||||
"pylint:",
|
||||
"type: ignore",
|
||||
"pytype:",
|
||||
"mypy:",
|
||||
"pyright:",
|
||||
"pyre-",
|
||||
]
|
||||
|
||||
|
||||
[build-system]
|
||||
@@ -135,15 +144,10 @@ exclude = ['src/**/*.sh']
|
||||
name = "google.adk"
|
||||
|
||||
[tool.isort]
|
||||
# Organize imports following Google style-guide
|
||||
force_single_line = true
|
||||
force_sort_within_sections = true
|
||||
honor_case_in_force_sorted_sections = true
|
||||
order_by_type = false
|
||||
sort_relative_in_force_sorted_sections = true
|
||||
multi_line_output = 3
|
||||
line_length = 200
|
||||
profile = "google"
|
||||
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -44,7 +44,7 @@ Args:
|
||||
callback_context: MUST be named 'callback_context' (enforced).
|
||||
|
||||
Returns:
|
||||
The content to return to the user. When set, the agent run will skipped and
|
||||
The content to return to the user. When set, the agent run will be skipped and
|
||||
the provided content will be returned to user.
|
||||
"""
|
||||
|
||||
@@ -55,8 +55,8 @@ Args:
|
||||
callback_context: MUST be named 'callback_context' (enforced).
|
||||
|
||||
Returns:
|
||||
The content to return to the user. When set, the agent run will skipped and
|
||||
the provided content will be appended to event history as agent response.
|
||||
The content to return to the user. When set, the provided content will be
|
||||
appended to event history as agent response.
|
||||
"""
|
||||
|
||||
|
||||
@@ -101,8 +101,8 @@ class BaseAgent(BaseModel):
|
||||
callback_context: MUST be named 'callback_context' (enforced).
|
||||
|
||||
Returns:
|
||||
The content to return to the user. When set, the agent run will skipped and
|
||||
the provided content will be returned to user.
|
||||
The content to return to the user. When set, the agent run will be skipped
|
||||
and the provided content will be returned to user.
|
||||
"""
|
||||
after_agent_callback: Optional[AfterAgentCallback] = None
|
||||
"""Callback signature that is invoked after the agent run.
|
||||
@@ -111,8 +111,8 @@ class BaseAgent(BaseModel):
|
||||
callback_context: MUST be named 'callback_context' (enforced).
|
||||
|
||||
Returns:
|
||||
The content to return to the user. When set, the agent run will skipped and
|
||||
the provided content will be appended to event history as agent response.
|
||||
The content to return to the user. When set, the provided content will be
|
||||
appended to event history as agent response.
|
||||
"""
|
||||
|
||||
@final
|
||||
|
||||
@@ -15,12 +15,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import AsyncGenerator
|
||||
from typing import Callable
|
||||
from typing import Literal
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
from typing import Any, AsyncGenerator, Awaitable, Callable, Literal, Optional, Union
|
||||
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel
|
||||
@@ -62,11 +57,11 @@ AfterModelCallback: TypeAlias = Callable[
|
||||
]
|
||||
BeforeToolCallback: TypeAlias = Callable[
|
||||
[BaseTool, dict[str, Any], ToolContext],
|
||||
Optional[dict],
|
||||
Union[Awaitable[Optional[dict]], Optional[dict]],
|
||||
]
|
||||
AfterToolCallback: TypeAlias = Callable[
|
||||
[BaseTool, dict[str, Any], ToolContext, dict],
|
||||
Optional[dict],
|
||||
Union[Awaitable[Optional[dict]], Optional[dict]],
|
||||
]
|
||||
|
||||
InstructionProvider: TypeAlias = Callable[[ReadonlyContext], str]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+54
-47
@@ -39,12 +39,12 @@ class InputFile(BaseModel):
|
||||
|
||||
async def run_input_file(
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
root_agent: LlmAgent,
|
||||
artifact_service: BaseArtifactService,
|
||||
session: Session,
|
||||
session_service: BaseSessionService,
|
||||
input_path: str,
|
||||
) -> None:
|
||||
) -> Session:
|
||||
runner = Runner(
|
||||
app_name=app_name,
|
||||
agent=root_agent,
|
||||
@@ -55,9 +55,11 @@ async def run_input_file(
|
||||
input_file = InputFile.model_validate_json(f.read())
|
||||
input_file.state['_time'] = datetime.now()
|
||||
|
||||
session.state = input_file.state
|
||||
session = session_service.create_session(
|
||||
app_name=app_name, user_id=user_id, state=input_file.state
|
||||
)
|
||||
for query in input_file.queries:
|
||||
click.echo(f'user: {query}')
|
||||
click.echo(f'[user]: {query}')
|
||||
content = types.Content(role='user', parts=[types.Part(text=query)])
|
||||
async for event in runner.run_async(
|
||||
user_id=session.user_id, session_id=session.id, new_message=content
|
||||
@@ -65,23 +67,23 @@ async def run_input_file(
|
||||
if event.content and event.content.parts:
|
||||
if text := ''.join(part.text or '' for part in event.content.parts):
|
||||
click.echo(f'[{event.author}]: {text}')
|
||||
return session
|
||||
|
||||
|
||||
async def run_interactively(
|
||||
app_name: str,
|
||||
root_agent: LlmAgent,
|
||||
artifact_service: BaseArtifactService,
|
||||
session: Session,
|
||||
session_service: BaseSessionService,
|
||||
) -> None:
|
||||
runner = Runner(
|
||||
app_name=app_name,
|
||||
app_name=session.app_name,
|
||||
agent=root_agent,
|
||||
artifact_service=artifact_service,
|
||||
session_service=session_service,
|
||||
)
|
||||
while True:
|
||||
query = input('user: ')
|
||||
query = input('[user]: ')
|
||||
if not query or not query.strip():
|
||||
continue
|
||||
if query == 'exit':
|
||||
@@ -100,7 +102,8 @@ async def run_cli(
|
||||
*,
|
||||
agent_parent_dir: str,
|
||||
agent_folder_name: str,
|
||||
json_file_path: Optional[str] = None,
|
||||
input_file: Optional[str] = None,
|
||||
saved_session_file: Optional[str] = None,
|
||||
save_session: bool,
|
||||
) -> None:
|
||||
"""Runs an interactive CLI for a certain agent.
|
||||
@@ -109,8 +112,11 @@ async def run_cli(
|
||||
agent_parent_dir: str, the absolute path of the parent folder of the agent
|
||||
folder.
|
||||
agent_folder_name: str, the name of the agent folder.
|
||||
json_file_path: Optional[str], the absolute path to the json file, either
|
||||
*.input.json or *.session.json.
|
||||
input_file: Optional[str], the absolute path to the json file that contains
|
||||
the initial session state and user queries, exclusive with
|
||||
saved_session_file.
|
||||
saved_session_file: Optional[str], the absolute path to the json file that
|
||||
contains a previously saved session, exclusive with input_file.
|
||||
save_session: bool, whether to save the session on exit.
|
||||
"""
|
||||
if agent_parent_dir not in sys.path:
|
||||
@@ -118,46 +124,50 @@ async def run_cli(
|
||||
|
||||
artifact_service = InMemoryArtifactService()
|
||||
session_service = InMemorySessionService()
|
||||
session = session_service.create_session(
|
||||
app_name=agent_folder_name, user_id='test_user'
|
||||
)
|
||||
|
||||
agent_module_path = os.path.join(agent_parent_dir, agent_folder_name)
|
||||
agent_module = importlib.import_module(agent_folder_name)
|
||||
user_id = 'test_user'
|
||||
session = session_service.create_session(
|
||||
app_name=agent_folder_name, user_id=user_id
|
||||
)
|
||||
root_agent = agent_module.agent.root_agent
|
||||
envs.load_dotenv_for_agent(agent_folder_name, agent_parent_dir)
|
||||
if json_file_path:
|
||||
if json_file_path.endswith('.input.json'):
|
||||
await run_input_file(
|
||||
app_name=agent_folder_name,
|
||||
root_agent=root_agent,
|
||||
artifact_service=artifact_service,
|
||||
session=session,
|
||||
session_service=session_service,
|
||||
input_path=json_file_path,
|
||||
)
|
||||
elif json_file_path.endswith('.session.json'):
|
||||
with open(json_file_path, 'r') as f:
|
||||
session = Session.model_validate_json(f.read())
|
||||
for content in session.get_contents():
|
||||
if content.role == 'user':
|
||||
print('user: ', content.parts[0].text)
|
||||
if input_file:
|
||||
session = await run_input_file(
|
||||
app_name=agent_folder_name,
|
||||
user_id=user_id,
|
||||
root_agent=root_agent,
|
||||
artifact_service=artifact_service,
|
||||
session_service=session_service,
|
||||
input_path=input_file,
|
||||
)
|
||||
elif saved_session_file:
|
||||
|
||||
loaded_session = None
|
||||
with open(saved_session_file, 'r') as f:
|
||||
loaded_session = Session.model_validate_json(f.read())
|
||||
|
||||
if loaded_session:
|
||||
for event in loaded_session.events:
|
||||
session_service.append_event(session, event)
|
||||
content = event.content
|
||||
if not content or not content.parts or not content.parts[0].text:
|
||||
continue
|
||||
if event.author == 'user':
|
||||
click.echo(f'[user]: {content.parts[0].text}')
|
||||
else:
|
||||
print(content.parts[0].text)
|
||||
await run_interactively(
|
||||
agent_folder_name,
|
||||
root_agent,
|
||||
artifact_service,
|
||||
session,
|
||||
session_service,
|
||||
)
|
||||
else:
|
||||
print(f'Unsupported file type: {json_file_path}')
|
||||
exit(1)
|
||||
click.echo(f'[{event.author}]: {content.parts[0].text}')
|
||||
|
||||
await run_interactively(
|
||||
root_agent,
|
||||
artifact_service,
|
||||
session,
|
||||
session_service,
|
||||
)
|
||||
else:
|
||||
print(f'Running agent {root_agent.name}, type exit to exit.')
|
||||
click.echo(f'Running agent {root_agent.name}, type exit to exit.')
|
||||
await run_interactively(
|
||||
agent_folder_name,
|
||||
root_agent,
|
||||
artifact_service,
|
||||
session,
|
||||
@@ -165,11 +175,8 @@ async def run_cli(
|
||||
)
|
||||
|
||||
if save_session:
|
||||
if json_file_path:
|
||||
session_path = json_file_path.replace('.input.json', '.session.json')
|
||||
else:
|
||||
session_id = input('Session ID to save: ')
|
||||
session_path = f'{agent_module_path}/{session_id}.session.json'
|
||||
session_id = input('Session ID to save: ')
|
||||
session_path = f'{agent_module_path}/{session_id}.session.json'
|
||||
|
||||
# Fetch the session again to get all the details.
|
||||
session = session_service.get_session(
|
||||
|
||||
@@ -96,6 +96,23 @@ def cli_create_cmd(
|
||||
)
|
||||
|
||||
|
||||
def validate_exclusive(ctx, param, value):
|
||||
# Store the validated parameters in the context
|
||||
if not hasattr(ctx, "exclusive_opts"):
|
||||
ctx.exclusive_opts = {}
|
||||
|
||||
# If this option has a value and we've already seen another exclusive option
|
||||
if value is not None and any(ctx.exclusive_opts.values()):
|
||||
exclusive_opt = next(key for key, val in ctx.exclusive_opts.items() if val)
|
||||
raise click.UsageError(
|
||||
f"Options '{param.name}' and '{exclusive_opt}' cannot be set together."
|
||||
)
|
||||
|
||||
# Record this option's value
|
||||
ctx.exclusive_opts[param.name] = value is not None
|
||||
return value
|
||||
|
||||
|
||||
@main.command("run")
|
||||
@click.option(
|
||||
"--save_session",
|
||||
@@ -105,13 +122,43 @@ def cli_create_cmd(
|
||||
default=False,
|
||||
help="Optional. Whether to save the session to a json file on exit.",
|
||||
)
|
||||
@click.option(
|
||||
"--replay",
|
||||
type=click.Path(
|
||||
exists=True, dir_okay=False, file_okay=True, resolve_path=True
|
||||
),
|
||||
help=(
|
||||
"The json file that contains the initial state of the session and user"
|
||||
" queries. A new session will be created using this state. And user"
|
||||
" queries are run againt the newly created session. Users cannot"
|
||||
" continue to interact with the agent."
|
||||
),
|
||||
callback=validate_exclusive,
|
||||
)
|
||||
@click.option(
|
||||
"--resume",
|
||||
type=click.Path(
|
||||
exists=True, dir_okay=False, file_okay=True, resolve_path=True
|
||||
),
|
||||
help=(
|
||||
"The json file that contains a previously saved session (by"
|
||||
"--save_session option). The previous session will be re-displayed. And"
|
||||
" user can continue to interact with the agent."
|
||||
),
|
||||
callback=validate_exclusive,
|
||||
)
|
||||
@click.argument(
|
||||
"agent",
|
||||
type=click.Path(
|
||||
exists=True, dir_okay=True, file_okay=False, resolve_path=True
|
||||
),
|
||||
)
|
||||
def cli_run(agent: str, save_session: bool):
|
||||
def cli_run(
|
||||
agent: str,
|
||||
save_session: bool,
|
||||
replay: Optional[str],
|
||||
resume: Optional[str],
|
||||
):
|
||||
"""Runs an interactive CLI for a certain agent.
|
||||
|
||||
AGENT: The path to the agent source code folder.
|
||||
@@ -129,6 +176,8 @@ def cli_run(agent: str, save_session: bool):
|
||||
run_cli(
|
||||
agent_parent_dir=agent_parent_folder,
|
||||
agent_folder_name=agent_folder_name,
|
||||
input_file=replay,
|
||||
saved_session_file=resume,
|
||||
save_session=save_session,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -48,8 +48,13 @@ class EventActions(BaseModel):
|
||||
"""The agent is escalating to a higher level agent."""
|
||||
|
||||
requested_auth_configs: dict[str, AuthConfig] = Field(default_factory=dict)
|
||||
"""Will only be set by a tool response indicating tool request euc.
|
||||
dict key is the function call id since one function call response (from model)
|
||||
could correspond to multiple function calls.
|
||||
dict value is the required auth config.
|
||||
"""Authentication configurations requested by tool responses.
|
||||
|
||||
This field will only be set by a tool response event indicating tool request
|
||||
auth credential.
|
||||
- Keys: The function call id. Since one function response event could contain
|
||||
multiple function responses that correspond to multiple function calls. Each
|
||||
function call could request different auth configs. This id is used to
|
||||
identify the function call.
|
||||
- Values: The requested auth config.
|
||||
"""
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import AsyncGenerator
|
||||
from typing import Generator
|
||||
from typing import Optional
|
||||
from typing import AsyncGenerator, Generator, Optional
|
||||
|
||||
from google.genai import types
|
||||
from typing_extensions import override
|
||||
@@ -202,8 +200,14 @@ def _get_contents(
|
||||
# Parse the events, leaving the contents and the function calls and
|
||||
# responses from the current agent.
|
||||
for event in events:
|
||||
if not event.content or not event.content.role:
|
||||
# Skip events without content, or generated neither by user nor by model.
|
||||
if (
|
||||
not event.content
|
||||
or not event.content.role
|
||||
or not event.content.parts
|
||||
or event.content.parts[0].text == ''
|
||||
):
|
||||
# Skip events without content, or generated neither by user nor by model
|
||||
# or has empty text.
|
||||
# E.g. events purely for mutating session states.
|
||||
continue
|
||||
if not _is_event_belongs_to_branch(current_branch, event):
|
||||
|
||||
@@ -151,28 +151,33 @@ async def handle_function_calls_async(
|
||||
# do not use "args" as the variable name, because it is a reserved keyword
|
||||
# in python debugger.
|
||||
function_args = function_call.args or {}
|
||||
function_response = None
|
||||
# Calls the tool if before_tool_callback does not exist or returns None.
|
||||
function_response: Optional[dict] = None
|
||||
|
||||
# before_tool_callback (sync or async)
|
||||
if agent.before_tool_callback:
|
||||
function_response = agent.before_tool_callback(
|
||||
tool=tool, args=function_args, tool_context=tool_context
|
||||
)
|
||||
if inspect.isawaitable(function_response):
|
||||
function_response = await function_response
|
||||
|
||||
if not function_response:
|
||||
function_response = await __call_tool_async(
|
||||
tool, args=function_args, tool_context=tool_context
|
||||
)
|
||||
|
||||
# Calls after_tool_callback if it exists.
|
||||
# after_tool_callback (sync or async)
|
||||
if agent.after_tool_callback:
|
||||
new_response = agent.after_tool_callback(
|
||||
altered_function_response = agent.after_tool_callback(
|
||||
tool=tool,
|
||||
args=function_args,
|
||||
tool_context=tool_context,
|
||||
tool_response=function_response,
|
||||
)
|
||||
if new_response:
|
||||
function_response = new_response
|
||||
if inspect.isawaitable(altered_function_response):
|
||||
altered_function_response = await altered_function_response
|
||||
if altered_function_response is not None:
|
||||
function_response = altered_function_response
|
||||
|
||||
if tool.is_long_running:
|
||||
# Allow long running function to return None to not provide function response.
|
||||
@@ -223,11 +228,17 @@ async def handle_function_calls_live(
|
||||
# in python debugger.
|
||||
function_args = function_call.args or {}
|
||||
function_response = None
|
||||
# Calls the tool if before_tool_callback does not exist or returns None.
|
||||
# # Calls the tool if before_tool_callback does not exist or returns None.
|
||||
# if agent.before_tool_callback:
|
||||
# function_response = agent.before_tool_callback(
|
||||
# tool, function_args, tool_context
|
||||
# )
|
||||
if agent.before_tool_callback:
|
||||
function_response = agent.before_tool_callback(
|
||||
tool, function_args, tool_context
|
||||
tool=tool, args=function_args, tool_context=tool_context
|
||||
)
|
||||
if inspect.isawaitable(function_response):
|
||||
function_response = await function_response
|
||||
|
||||
if not function_response:
|
||||
function_response = await _process_function_live_helper(
|
||||
@@ -235,15 +246,26 @@ async def handle_function_calls_live(
|
||||
)
|
||||
|
||||
# Calls after_tool_callback if it exists.
|
||||
# if agent.after_tool_callback:
|
||||
# new_response = agent.after_tool_callback(
|
||||
# tool,
|
||||
# function_args,
|
||||
# tool_context,
|
||||
# function_response,
|
||||
# )
|
||||
# if new_response:
|
||||
# function_response = new_response
|
||||
if agent.after_tool_callback:
|
||||
new_response = agent.after_tool_callback(
|
||||
tool,
|
||||
function_args,
|
||||
tool_context,
|
||||
function_response,
|
||||
altered_function_response = agent.after_tool_callback(
|
||||
tool=tool,
|
||||
args=function_args,
|
||||
tool_context=tool_context,
|
||||
tool_response=function_response,
|
||||
)
|
||||
if new_response:
|
||||
function_response = new_response
|
||||
if inspect.isawaitable(altered_function_response):
|
||||
altered_function_response = await altered_function_response
|
||||
if altered_function_response is not None:
|
||||
function_response = altered_function_response
|
||||
|
||||
if tool.is_long_running:
|
||||
# Allow async function to return None to not provide function response.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Utility functions for session service."""
|
||||
|
||||
import base64
|
||||
from typing import Any, Optional
|
||||
|
||||
from google.genai import types
|
||||
|
||||
|
||||
def encode_content(content: types.Content):
|
||||
"""Encodes a content object to a JSON dictionary."""
|
||||
encoded_content = content.model_dump(exclude_none=True)
|
||||
for p in encoded_content["parts"]:
|
||||
if "inline_data" in p:
|
||||
p["inline_data"]["data"] = base64.b64encode(
|
||||
p["inline_data"]["data"]
|
||||
).decode("utf-8")
|
||||
return encoded_content
|
||||
|
||||
|
||||
def decode_content(
|
||||
content: Optional[dict[str, Any]],
|
||||
) -> Optional[types.Content]:
|
||||
"""Decodes a content object from a JSON dictionary."""
|
||||
if not content:
|
||||
return None
|
||||
for p in content["parts"]:
|
||||
if "inline_data" in p:
|
||||
p["inline_data"]["data"] = base64.b64decode(p["inline_data"]["data"])
|
||||
return types.Content.model_validate(content)
|
||||
@@ -11,8 +11,6 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import base64
|
||||
import copy
|
||||
from datetime import datetime
|
||||
import json
|
||||
@@ -20,13 +18,13 @@ import logging
|
||||
from typing import Any, Optional
|
||||
import uuid
|
||||
|
||||
from google.genai import types
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import Dialect
|
||||
from sqlalchemy import ForeignKeyConstraint
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.engine import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
@@ -48,6 +46,7 @@ from typing_extensions import override
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from ..events.event import Event
|
||||
from . import _session_util
|
||||
from .base_session_service import BaseSessionService
|
||||
from .base_session_service import GetSessionConfig
|
||||
from .base_session_service import ListEventsResponse
|
||||
@@ -58,6 +57,9 @@ from .state import State
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_KEY_LENGTH = 128
|
||||
DEFAULT_MAX_VARCHAR_LENGTH = 256
|
||||
|
||||
|
||||
class DynamicJSON(TypeDecorator):
|
||||
"""A JSON-like type that uses JSONB on PostgreSQL and TEXT with JSON
|
||||
@@ -70,15 +72,16 @@ class DynamicJSON(TypeDecorator):
|
||||
def load_dialect_impl(self, dialect: Dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(postgresql.JSONB)
|
||||
else:
|
||||
return dialect.type_descriptor(Text) # Default to Text for other dialects
|
||||
if dialect.name == "mysql":
|
||||
# Use LONGTEXT for MySQL to address the data too long issue
|
||||
return dialect.type_descriptor(mysql.LONGTEXT)
|
||||
return dialect.type_descriptor(Text) # Default to Text for other dialects
|
||||
|
||||
def process_bind_param(self, value, dialect: Dialect):
|
||||
if value is not None:
|
||||
if dialect.name == "postgresql":
|
||||
return value # JSONB handles dict directly
|
||||
else:
|
||||
return json.dumps(value) # Serialize to JSON string for TEXT
|
||||
return json.dumps(value) # Serialize to JSON string for TEXT
|
||||
return value
|
||||
|
||||
def process_result_value(self, value, dialect: Dialect):
|
||||
@@ -92,17 +95,25 @@ class DynamicJSON(TypeDecorator):
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for database tables."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class StorageSession(Base):
|
||||
"""Represents a session stored in the database."""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
app_name: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
app_name: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
id: Mapped[str] = mapped_column(
|
||||
String, primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
String(DEFAULT_MAX_KEY_LENGTH),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
state: Mapped[MutableDict[str, Any]] = mapped_column(
|
||||
@@ -125,16 +136,27 @@ class StorageSession(Base):
|
||||
|
||||
class StorageEvent(Base):
|
||||
"""Represents an event stored in the database."""
|
||||
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
app_name: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
session_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
app_name: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
|
||||
invocation_id: Mapped[str] = mapped_column(String)
|
||||
author: Mapped[str] = mapped_column(String)
|
||||
branch: Mapped[str] = mapped_column(String, nullable=True)
|
||||
invocation_id: Mapped[str] = mapped_column(String(DEFAULT_MAX_VARCHAR_LENGTH))
|
||||
author: Mapped[str] = mapped_column(String(DEFAULT_MAX_VARCHAR_LENGTH))
|
||||
branch: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_VARCHAR_LENGTH), nullable=True
|
||||
)
|
||||
timestamp: Mapped[DateTime] = mapped_column(DateTime(), default=func.now())
|
||||
content: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=True)
|
||||
actions: Mapped[MutableDict[str, Any]] = mapped_column(PickleType)
|
||||
@@ -147,8 +169,10 @@ class StorageEvent(Base):
|
||||
)
|
||||
partial: Mapped[bool] = mapped_column(Boolean, nullable=True)
|
||||
turn_complete: Mapped[bool] = mapped_column(Boolean, nullable=True)
|
||||
error_code: Mapped[str] = mapped_column(String, nullable=True)
|
||||
error_message: Mapped[str] = mapped_column(String, nullable=True)
|
||||
error_code: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_VARCHAR_LENGTH), nullable=True
|
||||
)
|
||||
error_message: Mapped[str] = mapped_column(String(1024), nullable=True)
|
||||
interrupted: Mapped[bool] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
storage_session: Mapped[StorageSession] = relationship(
|
||||
@@ -182,9 +206,12 @@ class StorageEvent(Base):
|
||||
|
||||
class StorageAppState(Base):
|
||||
"""Represents an app state stored in the database."""
|
||||
|
||||
__tablename__ = "app_states"
|
||||
|
||||
app_name: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
app_name: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
state: Mapped[MutableDict[str, Any]] = mapped_column(
|
||||
MutableDict.as_mutable(DynamicJSON), default={}
|
||||
)
|
||||
@@ -192,13 +219,17 @@ class StorageAppState(Base):
|
||||
DateTime(), default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class StorageUserState(Base):
|
||||
"""Represents a user state stored in the database."""
|
||||
|
||||
__tablename__ = "user_states"
|
||||
|
||||
app_name: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
app_name: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(DEFAULT_MAX_KEY_LENGTH), primary_key=True
|
||||
)
|
||||
state: Mapped[MutableDict[str, Any]] = mapped_column(
|
||||
MutableDict.as_mutable(DynamicJSON), default={}
|
||||
)
|
||||
@@ -353,6 +384,7 @@ class DatabaseSessionService(BaseSessionService):
|
||||
else True
|
||||
)
|
||||
.limit(config.num_recent_events if config else None)
|
||||
.order_by(StorageEvent.timestamp.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -383,7 +415,7 @@ class DatabaseSessionService(BaseSessionService):
|
||||
author=e.author,
|
||||
branch=e.branch,
|
||||
invocation_id=e.invocation_id,
|
||||
content=_decode_content(e.content),
|
||||
content=_session_util.decode_content(e.content),
|
||||
actions=e.actions,
|
||||
timestamp=e.timestamp.timestamp(),
|
||||
long_running_tool_ids=e.long_running_tool_ids,
|
||||
@@ -506,15 +538,7 @@ class DatabaseSessionService(BaseSessionService):
|
||||
interrupted=event.interrupted,
|
||||
)
|
||||
if event.content:
|
||||
encoded_content = event.content.model_dump(exclude_none=True)
|
||||
# Workaround for multimodal Content throwing JSON not serializable
|
||||
# error with SQLAlchemy.
|
||||
for p in encoded_content["parts"]:
|
||||
if "inline_data" in p:
|
||||
p["inline_data"]["data"] = (
|
||||
base64.b64encode(p["inline_data"]["data"]).decode("utf-8"),
|
||||
)
|
||||
storage_event.content = encoded_content
|
||||
storage_event.content = _session_util.encode_content(event.content)
|
||||
|
||||
sessionFactory.add(storage_event)
|
||||
|
||||
@@ -574,14 +598,3 @@ def _merge_state(app_state, user_state, session_state):
|
||||
for key in user_state.keys():
|
||||
merged_state[State.USER_PREFIX + key] = user_state[key]
|
||||
return merged_state
|
||||
|
||||
|
||||
def _decode_content(
|
||||
content: Optional[dict[str, Any]],
|
||||
) -> Optional[types.Content]:
|
||||
if not content:
|
||||
return None
|
||||
for p in content["parts"]:
|
||||
if "inline_data" in p:
|
||||
p["inline_data"]["data"] = base64.b64decode(p["inline_data"]["data"][0])
|
||||
return types.Content.model_validate(content)
|
||||
|
||||
@@ -14,21 +14,23 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from dateutil.parser import isoparse
|
||||
from dateutil import parser
|
||||
from google import genai
|
||||
from typing_extensions import override
|
||||
|
||||
from ..events.event import Event
|
||||
from ..events.event_actions import EventActions
|
||||
from . import _session_util
|
||||
from .base_session_service import BaseSessionService
|
||||
from .base_session_service import GetSessionConfig
|
||||
from .base_session_service import ListEventsResponse
|
||||
from .base_session_service import ListSessionsResponse
|
||||
from .session import Session
|
||||
|
||||
|
||||
isoparse = parser.isoparse
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -289,7 +291,7 @@ def _convert_event_to_json(event: Event):
|
||||
}
|
||||
event_json['actions'] = actions_json
|
||||
if event.content:
|
||||
event_json['content'] = event.content.model_dump(exclude_none=True)
|
||||
event_json['content'] = _session_util.encode_content(event.content)
|
||||
if event.error_code:
|
||||
event_json['error_code'] = event.error_code
|
||||
if event.error_message:
|
||||
@@ -316,7 +318,7 @@ def _from_api_event(api_event: dict) -> Event:
|
||||
invocation_id=api_event['invocationId'],
|
||||
author=api_event['author'],
|
||||
actions=event_actions,
|
||||
content=api_event.get('content', None),
|
||||
content=_session_util.decode_content(api_event.get('content', None)),
|
||||
timestamp=isoparse(api_event['timestamp']).timestamp(),
|
||||
error_code=api_event.get('errorCode', None),
|
||||
error_message=api_event.get('errorMessage', None),
|
||||
|
||||
@@ -100,6 +100,7 @@ class ApiParameter(BaseModel):
|
||||
py_name: Optional[str] = ''
|
||||
type_value: type[Any] = Field(default=None, init_var=False)
|
||||
type_hint: str = Field(default=None, init_var=False)
|
||||
required: Optional[bool] = None
|
||||
|
||||
def model_post_init(self, _: Any):
|
||||
self.py_name = (
|
||||
|
||||
@@ -76,6 +76,8 @@ class OperationParser:
|
||||
description = param.description or ''
|
||||
location = param.in_ or ''
|
||||
schema = param.schema_ or {} # Use schema_ instead of .schema
|
||||
schema.description = description if schema.description is None and description != '' else schema.description
|
||||
required = param.required
|
||||
|
||||
self.params.append(
|
||||
ApiParameter(
|
||||
@@ -83,6 +85,7 @@ class OperationParser:
|
||||
param_location=location,
|
||||
param_schema=schema,
|
||||
description=description,
|
||||
required=required
|
||||
)
|
||||
)
|
||||
|
||||
@@ -230,7 +233,7 @@ class OperationParser:
|
||||
}
|
||||
return {
|
||||
'properties': properties,
|
||||
'required': [p.py_name for p in self.params],
|
||||
'required': [p.py_name for p in self.params if p.required is not False],
|
||||
'title': f"{self.operation.operationId or 'unnamed'}_Arguments",
|
||||
'type': 'object',
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@
|
||||
# limitations under the License.
|
||||
|
||||
# version: date+base_cl
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user