mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-12 07:45:14 -05:00
Compare commits
10 Commits
feat/dummy
...
fix/claude
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f8bf5f0c | ||
|
|
3f76f1318b | ||
|
|
b011289dd2 | ||
|
|
49c2f578b4 | ||
|
|
7150b7768d | ||
|
|
8c95b03636 | ||
|
|
4a8368887f | ||
|
|
d46e5e6b6a | ||
|
|
4e632bbd60 | ||
|
|
a78145505b |
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
from weakref import WeakValueDictionary
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
from openai.types.chat import (
|
from openai.types.chat import (
|
||||||
@@ -104,6 +104,26 @@ class ChatSession(BaseModel):
|
|||||||
successful_agent_runs: dict[str, int] = {}
|
successful_agent_runs: dict[str, int] = {}
|
||||||
successful_agent_schedules: dict[str, int] = {}
|
successful_agent_schedules: dict[str, int] = {}
|
||||||
|
|
||||||
|
def add_tool_call_to_current_turn(self, tool_call: dict) -> None:
|
||||||
|
"""Attach a tool_call to the current turn's assistant message.
|
||||||
|
|
||||||
|
Searches backwards for the most recent assistant message (stopping at
|
||||||
|
any user message boundary). If found, appends the tool_call to it.
|
||||||
|
Otherwise creates a new assistant message with the tool_call.
|
||||||
|
"""
|
||||||
|
for msg in reversed(self.messages):
|
||||||
|
if msg.role == "user":
|
||||||
|
break
|
||||||
|
if msg.role == "assistant":
|
||||||
|
if not msg.tool_calls:
|
||||||
|
msg.tool_calls = []
|
||||||
|
msg.tool_calls.append(tool_call)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.messages.append(
|
||||||
|
ChatMessage(role="assistant", content="", tool_calls=[tool_call])
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def new(user_id: str) -> "ChatSession":
|
def new(user_id: str) -> "ChatSession":
|
||||||
return ChatSession(
|
return ChatSession(
|
||||||
@@ -172,6 +192,47 @@ class ChatSession(BaseModel):
|
|||||||
successful_agent_schedules=successful_agent_schedules,
|
successful_agent_schedules=successful_agent_schedules,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_consecutive_assistant_messages(
|
||||||
|
messages: list[ChatCompletionMessageParam],
|
||||||
|
) -> list[ChatCompletionMessageParam]:
|
||||||
|
"""Merge consecutive assistant messages into single messages.
|
||||||
|
|
||||||
|
Long-running tool flows can create split assistant messages: one with
|
||||||
|
text content and another with tool_calls. Anthropic's API requires
|
||||||
|
tool_result blocks to reference a tool_use in the immediately preceding
|
||||||
|
assistant message, so these splits cause 400 errors via OpenRouter.
|
||||||
|
"""
|
||||||
|
if len(messages) < 2:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
result: list[ChatCompletionMessageParam] = [messages[0]]
|
||||||
|
for msg in messages[1:]:
|
||||||
|
prev = result[-1]
|
||||||
|
if prev.get("role") != "assistant" or msg.get("role") != "assistant":
|
||||||
|
result.append(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
prev = cast(ChatCompletionAssistantMessageParam, prev)
|
||||||
|
curr = cast(ChatCompletionAssistantMessageParam, msg)
|
||||||
|
|
||||||
|
curr_content = curr.get("content") or ""
|
||||||
|
if curr_content:
|
||||||
|
prev_content = prev.get("content") or ""
|
||||||
|
prev["content"] = (
|
||||||
|
f"{prev_content}\n{curr_content}" if prev_content else curr_content
|
||||||
|
)
|
||||||
|
|
||||||
|
curr_tool_calls = curr.get("tool_calls")
|
||||||
|
if curr_tool_calls:
|
||||||
|
prev_tool_calls = prev.get("tool_calls")
|
||||||
|
prev["tool_calls"] = (
|
||||||
|
list(prev_tool_calls) + list(curr_tool_calls)
|
||||||
|
if prev_tool_calls
|
||||||
|
else list(curr_tool_calls)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def to_openai_messages(self) -> list[ChatCompletionMessageParam]:
|
def to_openai_messages(self) -> list[ChatCompletionMessageParam]:
|
||||||
messages = []
|
messages = []
|
||||||
for message in self.messages:
|
for message in self.messages:
|
||||||
@@ -258,7 +319,7 @@ class ChatSession(BaseModel):
|
|||||||
name=message.name or "",
|
name=message.name or "",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return messages
|
return self._merge_consecutive_assistant_messages(messages)
|
||||||
|
|
||||||
|
|
||||||
async def _get_session_from_cache(session_id: str) -> ChatSession | None:
|
async def _get_session_from_cache(session_id: str) -> ChatSession | None:
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
|
from typing import cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from openai.types.chat import (
|
||||||
|
ChatCompletionAssistantMessageParam,
|
||||||
|
ChatCompletionMessageParam,
|
||||||
|
ChatCompletionToolMessageParam,
|
||||||
|
ChatCompletionUserMessageParam,
|
||||||
|
)
|
||||||
|
from openai.types.chat.chat_completion_message_tool_call_param import (
|
||||||
|
ChatCompletionMessageToolCallParam,
|
||||||
|
Function,
|
||||||
|
)
|
||||||
|
|
||||||
from .model import (
|
from .model import (
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -117,3 +129,205 @@ async def test_chatsession_db_storage(setup_test_user, test_user_id):
|
|||||||
loaded.tool_calls is not None
|
loaded.tool_calls is not None
|
||||||
), f"Tool calls missing for {orig.role} message"
|
), f"Tool calls missing for {orig.role} message"
|
||||||
assert len(orig.tool_calls) == len(loaded.tool_calls)
|
assert len(orig.tool_calls) == len(loaded.tool_calls)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# _merge_consecutive_assistant_messages #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_tc = ChatCompletionMessageToolCallParam(
|
||||||
|
id="tc1", type="function", function=Function(name="do_stuff", arguments="{}")
|
||||||
|
)
|
||||||
|
_tc2 = ChatCompletionMessageToolCallParam(
|
||||||
|
id="tc2", type="function", function=Function(name="other", arguments="{}")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_noop_when_no_consecutive_assistants():
|
||||||
|
"""Messages without consecutive assistants are returned unchanged."""
|
||||||
|
msgs = [
|
||||||
|
ChatCompletionUserMessageParam(role="user", content="hi"),
|
||||||
|
ChatCompletionAssistantMessageParam(role="assistant", content="hello"),
|
||||||
|
ChatCompletionUserMessageParam(role="user", content="bye"),
|
||||||
|
]
|
||||||
|
merged = ChatSession._merge_consecutive_assistant_messages(msgs)
|
||||||
|
assert len(merged) == 3
|
||||||
|
assert [m["role"] for m in merged] == ["user", "assistant", "user"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_splits_text_and_tool_calls():
|
||||||
|
"""The exact bug scenario: text-only assistant followed by tool_calls-only assistant."""
|
||||||
|
msgs = [
|
||||||
|
ChatCompletionUserMessageParam(role="user", content="build agent"),
|
||||||
|
ChatCompletionAssistantMessageParam(
|
||||||
|
role="assistant", content="Let me build that"
|
||||||
|
),
|
||||||
|
ChatCompletionAssistantMessageParam(
|
||||||
|
role="assistant", content="", tool_calls=[_tc]
|
||||||
|
),
|
||||||
|
ChatCompletionToolMessageParam(role="tool", content="ok", tool_call_id="tc1"),
|
||||||
|
]
|
||||||
|
merged = ChatSession._merge_consecutive_assistant_messages(msgs)
|
||||||
|
|
||||||
|
assert len(merged) == 3
|
||||||
|
assert merged[0]["role"] == "user"
|
||||||
|
assert merged[2]["role"] == "tool"
|
||||||
|
a = cast(ChatCompletionAssistantMessageParam, merged[1])
|
||||||
|
assert a["role"] == "assistant"
|
||||||
|
assert a.get("content") == "Let me build that"
|
||||||
|
assert a.get("tool_calls") == [_tc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_combines_tool_calls_from_both():
|
||||||
|
"""Both consecutive assistants have tool_calls — they get merged."""
|
||||||
|
msgs: list[ChatCompletionAssistantMessageParam] = [
|
||||||
|
ChatCompletionAssistantMessageParam(
|
||||||
|
role="assistant", content="text", tool_calls=[_tc]
|
||||||
|
),
|
||||||
|
ChatCompletionAssistantMessageParam(
|
||||||
|
role="assistant", content="", tool_calls=[_tc2]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
merged = ChatSession._merge_consecutive_assistant_messages(msgs) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert len(merged) == 1
|
||||||
|
a = cast(ChatCompletionAssistantMessageParam, merged[0])
|
||||||
|
assert a.get("tool_calls") == [_tc, _tc2]
|
||||||
|
assert a.get("content") == "text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_three_consecutive_assistants():
|
||||||
|
"""Three consecutive assistants collapse into one."""
|
||||||
|
msgs: list[ChatCompletionAssistantMessageParam] = [
|
||||||
|
ChatCompletionAssistantMessageParam(role="assistant", content="a"),
|
||||||
|
ChatCompletionAssistantMessageParam(role="assistant", content="b"),
|
||||||
|
ChatCompletionAssistantMessageParam(
|
||||||
|
role="assistant", content="", tool_calls=[_tc]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
merged = ChatSession._merge_consecutive_assistant_messages(msgs) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert len(merged) == 1
|
||||||
|
a = cast(ChatCompletionAssistantMessageParam, merged[0])
|
||||||
|
assert a.get("content") == "a\nb"
|
||||||
|
assert a.get("tool_calls") == [_tc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_empty_and_single_message():
|
||||||
|
"""Edge cases: empty list and single message."""
|
||||||
|
assert ChatSession._merge_consecutive_assistant_messages([]) == []
|
||||||
|
|
||||||
|
single: list[ChatCompletionMessageParam] = [
|
||||||
|
ChatCompletionUserMessageParam(role="user", content="hi")
|
||||||
|
]
|
||||||
|
assert ChatSession._merge_consecutive_assistant_messages(single) == single
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# add_tool_call_to_current_turn #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_raw_tc = {
|
||||||
|
"id": "tc1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "f", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
_raw_tc2 = {
|
||||||
|
"id": "tc2",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "g", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_tool_call_appends_to_existing_assistant():
|
||||||
|
"""When the last assistant is from the current turn, tool_call is added to it."""
|
||||||
|
session = ChatSession.new(user_id="u")
|
||||||
|
session.messages = [
|
||||||
|
ChatMessage(role="user", content="hi"),
|
||||||
|
ChatMessage(role="assistant", content="working on it"),
|
||||||
|
]
|
||||||
|
session.add_tool_call_to_current_turn(_raw_tc)
|
||||||
|
|
||||||
|
assert len(session.messages) == 2 # no new message created
|
||||||
|
assert session.messages[1].tool_calls == [_raw_tc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_tool_call_creates_assistant_when_none_exists():
|
||||||
|
"""When there's no current-turn assistant, a new one is created."""
|
||||||
|
session = ChatSession.new(user_id="u")
|
||||||
|
session.messages = [
|
||||||
|
ChatMessage(role="user", content="hi"),
|
||||||
|
]
|
||||||
|
session.add_tool_call_to_current_turn(_raw_tc)
|
||||||
|
|
||||||
|
assert len(session.messages) == 2
|
||||||
|
assert session.messages[1].role == "assistant"
|
||||||
|
assert session.messages[1].tool_calls == [_raw_tc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_tool_call_does_not_cross_user_boundary():
|
||||||
|
"""A user message acts as a boundary — previous assistant is not modified."""
|
||||||
|
session = ChatSession.new(user_id="u")
|
||||||
|
session.messages = [
|
||||||
|
ChatMessage(role="assistant", content="old turn"),
|
||||||
|
ChatMessage(role="user", content="new message"),
|
||||||
|
]
|
||||||
|
session.add_tool_call_to_current_turn(_raw_tc)
|
||||||
|
|
||||||
|
assert len(session.messages) == 3 # new assistant was created
|
||||||
|
assert session.messages[0].tool_calls is None # old assistant untouched
|
||||||
|
assert session.messages[2].role == "assistant"
|
||||||
|
assert session.messages[2].tool_calls == [_raw_tc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_tool_call_multiple_times():
|
||||||
|
"""Multiple long-running tool calls accumulate on the same assistant."""
|
||||||
|
session = ChatSession.new(user_id="u")
|
||||||
|
session.messages = [
|
||||||
|
ChatMessage(role="user", content="hi"),
|
||||||
|
ChatMessage(role="assistant", content="doing stuff"),
|
||||||
|
]
|
||||||
|
session.add_tool_call_to_current_turn(_raw_tc)
|
||||||
|
# Simulate a pending tool result in between (like _yield_tool_call does)
|
||||||
|
session.messages.append(
|
||||||
|
ChatMessage(role="tool", content="pending", tool_call_id="tc1")
|
||||||
|
)
|
||||||
|
session.add_tool_call_to_current_turn(_raw_tc2)
|
||||||
|
|
||||||
|
assert len(session.messages) == 3 # user, assistant, tool — no extra assistant
|
||||||
|
assert session.messages[1].tool_calls == [_raw_tc, _raw_tc2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_openai_messages_merges_split_assistants():
|
||||||
|
"""End-to-end: session with split assistants produces valid OpenAI messages."""
|
||||||
|
session = ChatSession.new(user_id="u")
|
||||||
|
session.messages = [
|
||||||
|
ChatMessage(role="user", content="build agent"),
|
||||||
|
ChatMessage(role="assistant", content="Let me build that"),
|
||||||
|
ChatMessage(
|
||||||
|
role="assistant",
|
||||||
|
content="",
|
||||||
|
tool_calls=[
|
||||||
|
{
|
||||||
|
"id": "tc1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "create_agent", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ChatMessage(role="tool", content="done", tool_call_id="tc1"),
|
||||||
|
ChatMessage(role="assistant", content="Saved!"),
|
||||||
|
ChatMessage(role="user", content="show me an example run"),
|
||||||
|
]
|
||||||
|
openai_msgs = session.to_openai_messages()
|
||||||
|
|
||||||
|
# The two consecutive assistants at index 1,2 should be merged
|
||||||
|
roles = [m["role"] for m in openai_msgs]
|
||||||
|
assert roles == ["user", "assistant", "tool", "assistant", "user"]
|
||||||
|
|
||||||
|
# The merged assistant should have both content and tool_calls
|
||||||
|
merged = cast(ChatCompletionAssistantMessageParam, openai_msgs[1])
|
||||||
|
assert merged.get("content") == "Let me build that"
|
||||||
|
tc_list = merged.get("tool_calls")
|
||||||
|
assert tc_list is not None and len(list(tc_list)) == 1
|
||||||
|
assert list(tc_list)[0]["id"] == "tc1"
|
||||||
|
|||||||
@@ -800,9 +800,13 @@ async def stream_chat_completion(
|
|||||||
# Build the messages list in the correct order
|
# Build the messages list in the correct order
|
||||||
messages_to_save: list[ChatMessage] = []
|
messages_to_save: list[ChatMessage] = []
|
||||||
|
|
||||||
# Add assistant message with tool_calls if any
|
# Add assistant message with tool_calls if any.
|
||||||
|
# Use extend (not assign) to preserve tool_calls already added by
|
||||||
|
# _yield_tool_call for long-running tools.
|
||||||
if accumulated_tool_calls:
|
if accumulated_tool_calls:
|
||||||
assistant_response.tool_calls = accumulated_tool_calls
|
if not assistant_response.tool_calls:
|
||||||
|
assistant_response.tool_calls = []
|
||||||
|
assistant_response.tool_calls.extend(accumulated_tool_calls)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
|
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
|
||||||
)
|
)
|
||||||
@@ -1404,13 +1408,9 @@ async def _yield_tool_call(
|
|||||||
operation_id=operation_id,
|
operation_id=operation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save assistant message with tool_call FIRST (required by LLM)
|
# Attach the tool_call to the current turn's assistant message
|
||||||
assistant_message = ChatMessage(
|
# (or create one if this is a tool-only response with no text).
|
||||||
role="assistant",
|
session.add_tool_call_to_current_turn(tool_calls[yield_idx])
|
||||||
content="",
|
|
||||||
tool_calls=[tool_calls[yield_idx]],
|
|
||||||
)
|
|
||||||
session.messages.append(assistant_message)
|
|
||||||
|
|
||||||
# Then save pending tool result
|
# Then save pending tool result
|
||||||
pending_message = ChatMessage(
|
pending_message = ChatMessage(
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
"""Dummy Agent Generator for testing.
|
|
||||||
|
|
||||||
Returns mock responses matching the format expected from the external service.
|
|
||||||
Enable via AGENTGENERATOR_USE_DUMMY=true in settings.
|
|
||||||
|
|
||||||
WARNING: This is for testing only. Do not use in production.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Dummy decomposition result (instructions type)
|
|
||||||
DUMMY_DECOMPOSITION_RESULT: dict[str, Any] = {
|
|
||||||
"type": "instructions",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"description": "Get input from user",
|
|
||||||
"action": "input",
|
|
||||||
"block_name": "AgentInputBlock",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Process the input",
|
|
||||||
"action": "process",
|
|
||||||
"block_name": "TextFormatterBlock",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Return output to user",
|
|
||||||
"action": "output",
|
|
||||||
"block_name": "AgentOutputBlock",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Block IDs from backend/blocks/io.py
|
|
||||||
AGENT_INPUT_BLOCK_ID = "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b"
|
|
||||||
AGENT_OUTPUT_BLOCK_ID = "363ae599-353e-4804-937e-b2ee3cef3da4"
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_dummy_agent_json() -> dict[str, Any]:
|
|
||||||
"""Generate a minimal valid agent JSON for testing."""
|
|
||||||
input_node_id = str(uuid.uuid4())
|
|
||||||
output_node_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"version": 1,
|
|
||||||
"is_active": True,
|
|
||||||
"name": "Dummy Test Agent",
|
|
||||||
"description": "A dummy agent generated for testing purposes",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": input_node_id,
|
|
||||||
"block_id": AGENT_INPUT_BLOCK_ID,
|
|
||||||
"input_default": {
|
|
||||||
"name": "input",
|
|
||||||
"title": "Input",
|
|
||||||
"description": "Enter your input",
|
|
||||||
"placeholder_values": [],
|
|
||||||
},
|
|
||||||
"metadata": {"position": {"x": 0, "y": 0}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": output_node_id,
|
|
||||||
"block_id": AGENT_OUTPUT_BLOCK_ID,
|
|
||||||
"input_default": {
|
|
||||||
"name": "output",
|
|
||||||
"title": "Output",
|
|
||||||
"description": "Agent output",
|
|
||||||
"format": "{output}",
|
|
||||||
},
|
|
||||||
"metadata": {"position": {"x": 400, "y": 0}},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"source_id": input_node_id,
|
|
||||||
"sink_id": output_node_id,
|
|
||||||
"source_name": "result",
|
|
||||||
"sink_name": "value",
|
|
||||||
"is_static": False,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def decompose_goal_dummy(
|
|
||||||
description: str,
|
|
||||||
context: str = "",
|
|
||||||
library_agents: list[dict[str, Any]] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return dummy decomposition result."""
|
|
||||||
logger.info("Using dummy agent generator for decompose_goal")
|
|
||||||
return DUMMY_DECOMPOSITION_RESULT.copy()
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_agent_dummy(
|
|
||||||
instructions: dict[str, Any],
|
|
||||||
library_agents: list[dict[str, Any]] | None = None,
|
|
||||||
operation_id: str | None = None,
|
|
||||||
task_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return dummy agent JSON."""
|
|
||||||
logger.info("Using dummy agent generator for generate_agent")
|
|
||||||
return _generate_dummy_agent_json()
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_agent_patch_dummy(
|
|
||||||
update_request: str,
|
|
||||||
current_agent: dict[str, Any],
|
|
||||||
library_agents: list[dict[str, Any]] | None = None,
|
|
||||||
operation_id: str | None = None,
|
|
||||||
task_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return dummy patched agent (returns the current agent with updated description)."""
|
|
||||||
logger.info("Using dummy agent generator for generate_agent_patch")
|
|
||||||
patched = current_agent.copy()
|
|
||||||
patched["description"] = (
|
|
||||||
f"{current_agent.get('description', '')} (updated: {update_request})"
|
|
||||||
)
|
|
||||||
return patched
|
|
||||||
|
|
||||||
|
|
||||||
async def customize_template_dummy(
|
|
||||||
template_agent: dict[str, Any],
|
|
||||||
modification_request: str,
|
|
||||||
context: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return dummy customized template (returns template with updated description)."""
|
|
||||||
logger.info("Using dummy agent generator for customize_template")
|
|
||||||
customized = template_agent.copy()
|
|
||||||
customized["description"] = (
|
|
||||||
f"{template_agent.get('description', '')} (customized: {modification_request})"
|
|
||||||
)
|
|
||||||
return customized
|
|
||||||
|
|
||||||
|
|
||||||
async def get_blocks_dummy() -> list[dict[str, Any]]:
|
|
||||||
"""Return dummy blocks list."""
|
|
||||||
logger.info("Using dummy agent generator for get_blocks")
|
|
||||||
return [
|
|
||||||
{"id": AGENT_INPUT_BLOCK_ID, "name": "AgentInputBlock"},
|
|
||||||
{"id": AGENT_OUTPUT_BLOCK_ID, "name": "AgentOutputBlock"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def health_check_dummy() -> bool:
|
|
||||||
"""Always returns healthy for dummy service."""
|
|
||||||
return True
|
|
||||||
@@ -12,19 +12,8 @@ import httpx
|
|||||||
|
|
||||||
from backend.util.settings import Settings
|
from backend.util.settings import Settings
|
||||||
|
|
||||||
from .dummy import (
|
|
||||||
customize_template_dummy,
|
|
||||||
decompose_goal_dummy,
|
|
||||||
generate_agent_dummy,
|
|
||||||
generate_agent_patch_dummy,
|
|
||||||
get_blocks_dummy,
|
|
||||||
health_check_dummy,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_dummy_mode_warned = False
|
|
||||||
|
|
||||||
|
|
||||||
def _create_error_response(
|
def _create_error_response(
|
||||||
error_message: str,
|
error_message: str,
|
||||||
@@ -101,26 +90,10 @@ def _get_settings() -> Settings:
|
|||||||
return _settings
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
def _is_dummy_mode() -> bool:
|
|
||||||
"""Check if dummy mode is enabled for testing."""
|
|
||||||
global _dummy_mode_warned
|
|
||||||
settings = _get_settings()
|
|
||||||
is_dummy = bool(settings.config.agentgenerator_use_dummy)
|
|
||||||
if is_dummy and not _dummy_mode_warned:
|
|
||||||
logger.warning(
|
|
||||||
"Agent Generator running in DUMMY MODE - returning mock responses. "
|
|
||||||
"Do not use in production!"
|
|
||||||
)
|
|
||||||
_dummy_mode_warned = True
|
|
||||||
return is_dummy
|
|
||||||
|
|
||||||
|
|
||||||
def is_external_service_configured() -> bool:
|
def is_external_service_configured() -> bool:
|
||||||
"""Check if external Agent Generator service is configured (or dummy mode)."""
|
"""Check if external Agent Generator service is configured."""
|
||||||
settings = _get_settings()
|
settings = _get_settings()
|
||||||
return bool(settings.config.agentgenerator_host) or bool(
|
return bool(settings.config.agentgenerator_host)
|
||||||
settings.config.agentgenerator_use_dummy
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_base_url() -> str:
|
def _get_base_url() -> str:
|
||||||
@@ -164,9 +137,6 @@ async def decompose_goal_external(
|
|||||||
- {"type": "error", "error": "...", "error_type": "..."} on error
|
- {"type": "error", "error": "...", "error_type": "..."} on error
|
||||||
Or None on unexpected error
|
Or None on unexpected error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await decompose_goal_dummy(description, context, library_agents)
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
if context:
|
if context:
|
||||||
@@ -256,11 +226,6 @@ async def generate_agent_external(
|
|||||||
Returns:
|
Returns:
|
||||||
Agent JSON dict, {"status": "accepted"} for async, or error dict {"type": "error", ...} on error
|
Agent JSON dict, {"status": "accepted"} for async, or error dict {"type": "error", ...} on error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await generate_agent_dummy(
|
|
||||||
instructions, library_agents, operation_id, task_id
|
|
||||||
)
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
# Build request payload
|
# Build request payload
|
||||||
@@ -332,11 +297,6 @@ async def generate_agent_patch_external(
|
|||||||
Returns:
|
Returns:
|
||||||
Updated agent JSON, clarifying questions dict, {"status": "accepted"} for async, or error dict on error
|
Updated agent JSON, clarifying questions dict, {"status": "accepted"} for async, or error dict on error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await generate_agent_patch_dummy(
|
|
||||||
update_request, current_agent, library_agents, operation_id, task_id
|
|
||||||
)
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
# Build request payload
|
# Build request payload
|
||||||
@@ -423,11 +383,6 @@ async def customize_template_external(
|
|||||||
Returns:
|
Returns:
|
||||||
Customized agent JSON, clarifying questions dict, or error dict on error
|
Customized agent JSON, clarifying questions dict, or error dict on error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await customize_template_dummy(
|
|
||||||
template_agent, modification_request, context
|
|
||||||
)
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
request = modification_request
|
request = modification_request
|
||||||
@@ -490,9 +445,6 @@ async def get_blocks_external() -> list[dict[str, Any]] | None:
|
|||||||
Returns:
|
Returns:
|
||||||
List of block info dicts or None on error
|
List of block info dicts or None on error
|
||||||
"""
|
"""
|
||||||
if _is_dummy_mode():
|
|
||||||
return await get_blocks_dummy()
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -526,9 +478,6 @@ async def health_check() -> bool:
|
|||||||
if not is_external_service_configured():
|
if not is_external_service_configured():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if _is_dummy_mode():
|
|
||||||
return await health_check_dummy()
|
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
@@ -21,6 +23,11 @@ from backend.data.model import (
|
|||||||
)
|
)
|
||||||
from backend.integrations.providers import ProviderName
|
from backend.integrations.providers import ProviderName
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum size for binary files to extract (50MB)
|
||||||
|
MAX_BINARY_FILE_SIZE = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class ClaudeCodeExecutionError(Exception):
|
class ClaudeCodeExecutionError(Exception):
|
||||||
"""Exception raised when Claude Code execution fails.
|
"""Exception raised when Claude Code execution fails.
|
||||||
@@ -180,7 +187,9 @@ class ClaudeCodeBlock(Block):
|
|||||||
path: str
|
path: str
|
||||||
relative_path: str # Path relative to working directory (for GitHub, etc.)
|
relative_path: str # Path relative to working directory (for GitHub, etc.)
|
||||||
name: str
|
name: str
|
||||||
content: str
|
content: str # Text content for text files, empty string for binary files
|
||||||
|
is_binary: bool = False # True if this is a binary file
|
||||||
|
content_base64: Optional[str] = None # Base64-encoded content for binary files
|
||||||
|
|
||||||
class Output(BlockSchemaOutput):
|
class Output(BlockSchemaOutput):
|
||||||
response: str = SchemaField(
|
response: str = SchemaField(
|
||||||
@@ -188,8 +197,11 @@ class ClaudeCodeBlock(Block):
|
|||||||
)
|
)
|
||||||
files: list["ClaudeCodeBlock.FileOutput"] = SchemaField(
|
files: list["ClaudeCodeBlock.FileOutput"] = SchemaField(
|
||||||
description=(
|
description=(
|
||||||
"List of text files created/modified by Claude Code during this execution. "
|
"List of files created/modified by Claude Code during this execution. "
|
||||||
"Each file has 'path', 'relative_path', 'name', and 'content' fields."
|
"Each file has 'path', 'relative_path', 'name', 'content', 'is_binary', "
|
||||||
|
"and 'content_base64' fields. For text files, 'content' contains the text "
|
||||||
|
"and 'is_binary' is False. For binary files (PDFs, images, etc.), "
|
||||||
|
"'is_binary' is True and 'content_base64' contains the base64-encoded data."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
conversation_history: str = SchemaField(
|
conversation_history: str = SchemaField(
|
||||||
@@ -252,6 +264,8 @@ class ClaudeCodeBlock(Block):
|
|||||||
"relative_path": "index.html",
|
"relative_path": "index.html",
|
||||||
"name": "index.html",
|
"name": "index.html",
|
||||||
"content": "<html>Hello World</html>",
|
"content": "<html>Hello World</html>",
|
||||||
|
"is_binary": False,
|
||||||
|
"content_base64": None,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -272,6 +286,8 @@ class ClaudeCodeBlock(Block):
|
|||||||
relative_path="index.html",
|
relative_path="index.html",
|
||||||
name="index.html",
|
name="index.html",
|
||||||
content="<html>Hello World</html>",
|
content="<html>Hello World</html>",
|
||||||
|
is_binary=False,
|
||||||
|
content_base64=None,
|
||||||
)
|
)
|
||||||
], # files
|
], # files
|
||||||
"User: Create a hello world HTML file\n"
|
"User: Create a hello world HTML file\n"
|
||||||
@@ -531,7 +547,6 @@ class ClaudeCodeBlock(Block):
|
|||||||
".env",
|
".env",
|
||||||
".gitignore",
|
".gitignore",
|
||||||
".dockerfile",
|
".dockerfile",
|
||||||
"Dockerfile",
|
|
||||||
".vue",
|
".vue",
|
||||||
".svelte",
|
".svelte",
|
||||||
".astro",
|
".astro",
|
||||||
@@ -540,6 +555,44 @@ class ClaudeCodeBlock(Block):
|
|||||||
".tex",
|
".tex",
|
||||||
".csv",
|
".csv",
|
||||||
".log",
|
".log",
|
||||||
|
".svg", # SVG is XML-based text
|
||||||
|
}
|
||||||
|
|
||||||
|
# Binary file extensions we can read and base64-encode
|
||||||
|
binary_extensions = {
|
||||||
|
# Images
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".ico",
|
||||||
|
".bmp",
|
||||||
|
".tiff",
|
||||||
|
".tif",
|
||||||
|
# Documents
|
||||||
|
".pdf",
|
||||||
|
# Archives (useful for downloads)
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".gz",
|
||||||
|
".7z",
|
||||||
|
# Audio/Video (if small enough)
|
||||||
|
".mp3",
|
||||||
|
".wav",
|
||||||
|
".mp4",
|
||||||
|
".webm",
|
||||||
|
# Other binary formats
|
||||||
|
".woff",
|
||||||
|
".woff2",
|
||||||
|
".ttf",
|
||||||
|
".otf",
|
||||||
|
".eot",
|
||||||
|
".bin",
|
||||||
|
".exe",
|
||||||
|
".dll",
|
||||||
|
".so",
|
||||||
|
".dylib",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -564,10 +617,26 @@ class ClaudeCodeBlock(Block):
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if it's a text file we can read
|
# Check if it's a text file we can read (case-insensitive)
|
||||||
|
file_path_lower = file_path.lower()
|
||||||
is_text = any(
|
is_text = any(
|
||||||
file_path.endswith(ext) for ext in text_extensions
|
file_path_lower.endswith(ext) for ext in text_extensions
|
||||||
) or file_path.endswith("Dockerfile")
|
) or file_path_lower.endswith("dockerfile")
|
||||||
|
|
||||||
|
# Check if it's a binary file we should extract
|
||||||
|
is_binary = any(
|
||||||
|
file_path_lower.endswith(ext) for ext in binary_extensions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Helper to extract filename and relative path
|
||||||
|
def get_file_info(path: str, work_dir: str) -> tuple[str, str]:
|
||||||
|
name = path.split("/")[-1]
|
||||||
|
rel_path = path
|
||||||
|
if path.startswith(work_dir):
|
||||||
|
rel_path = path[len(work_dir) :]
|
||||||
|
if rel_path.startswith("/"):
|
||||||
|
rel_path = rel_path[1:]
|
||||||
|
return name, rel_path
|
||||||
|
|
||||||
if is_text:
|
if is_text:
|
||||||
try:
|
try:
|
||||||
@@ -576,32 +645,75 @@ class ClaudeCodeBlock(Block):
|
|||||||
if isinstance(content, bytes):
|
if isinstance(content, bytes):
|
||||||
content = content.decode("utf-8", errors="replace")
|
content = content.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
# Extract filename from path
|
file_name, relative_path = get_file_info(
|
||||||
file_name = file_path.split("/")[-1]
|
file_path, working_directory
|
||||||
|
)
|
||||||
# Calculate relative path by stripping working directory
|
|
||||||
relative_path = file_path
|
|
||||||
if file_path.startswith(working_directory):
|
|
||||||
relative_path = file_path[len(working_directory) :]
|
|
||||||
# Remove leading slash if present
|
|
||||||
if relative_path.startswith("/"):
|
|
||||||
relative_path = relative_path[1:]
|
|
||||||
|
|
||||||
files.append(
|
files.append(
|
||||||
ClaudeCodeBlock.FileOutput(
|
ClaudeCodeBlock.FileOutput(
|
||||||
path=file_path,
|
path=file_path,
|
||||||
relative_path=relative_path,
|
relative_path=relative_path,
|
||||||
name=file_name,
|
name=file_name,
|
||||||
content=content,
|
content=content,
|
||||||
|
is_binary=False,
|
||||||
|
content_base64=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# Skip files that can't be read
|
logger.warning(f"Failed to read text file {file_path}: {e}")
|
||||||
pass
|
elif is_binary:
|
||||||
|
try:
|
||||||
|
# Check file size before reading to avoid OOM
|
||||||
|
stat_result = await sandbox.commands.run(
|
||||||
|
f"stat -c %s {shlex.quote(file_path)} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
stat_result.exit_code != 0
|
||||||
|
or not stat_result.stdout
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"Skipping binary file {file_path}: "
|
||||||
|
f"could not determine file size"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
file_size = int(stat_result.stdout.strip())
|
||||||
|
if file_size > MAX_BINARY_FILE_SIZE:
|
||||||
|
logger.warning(
|
||||||
|
f"Skipping binary file {file_path}: "
|
||||||
|
f"size {file_size} exceeds limit "
|
||||||
|
f"{MAX_BINARY_FILE_SIZE}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
except Exception:
|
# Read binary file as bytes using format="bytes"
|
||||||
# If file extraction fails, return empty results
|
content_bytes = await sandbox.files.read(
|
||||||
pass
|
file_path, format="bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Base64 encode the binary content
|
||||||
|
content_b64 = base64.b64encode(content_bytes).decode(
|
||||||
|
"ascii"
|
||||||
|
)
|
||||||
|
|
||||||
|
file_name, relative_path = get_file_info(
|
||||||
|
file_path, working_directory
|
||||||
|
)
|
||||||
|
files.append(
|
||||||
|
ClaudeCodeBlock.FileOutput(
|
||||||
|
path=file_path,
|
||||||
|
relative_path=relative_path,
|
||||||
|
name=file_name,
|
||||||
|
content="", # Empty for binary files
|
||||||
|
is_binary=True,
|
||||||
|
content_base64=content_b64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to read binary file {file_path}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"File extraction failed: {e}")
|
||||||
|
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|||||||
@@ -368,10 +368,6 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
|
|||||||
default=600,
|
default=600,
|
||||||
description="The timeout in seconds for Agent Generator service requests (includes retries for rate limits)",
|
description="The timeout in seconds for Agent Generator service requests (includes retries for rate limits)",
|
||||||
)
|
)
|
||||||
agentgenerator_use_dummy: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Use dummy agent generator responses for testing (bypasses external service)",
|
|
||||||
)
|
|
||||||
|
|
||||||
enable_example_blocks: bool = Field(
|
enable_example_blocks: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ When activated, the block:
|
|||||||
- Install dependencies (npm, pip, etc.)
|
- Install dependencies (npm, pip, etc.)
|
||||||
- Run terminal commands
|
- Run terminal commands
|
||||||
- Build and test applications
|
- Build and test applications
|
||||||
5. Extracts all text files created/modified during execution
|
5. Extracts all text and binary files created/modified during execution
|
||||||
6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks
|
6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks
|
||||||
|
|
||||||
The block supports conversation continuation through three mechanisms:
|
The block supports conversation continuation through three mechanisms:
|
||||||
@@ -42,7 +42,7 @@ The block supports conversation continuation through three mechanisms:
|
|||||||
| Output | Description |
|
| Output | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| Response | The output/response from Claude Code execution |
|
| Response | The output/response from Claude Code execution |
|
||||||
| Files | List of text files created/modified during execution. Each file includes path, relative_path, name, and content fields |
|
| Files | List of files created/modified during execution. Each file includes path, relative_path, name, content, is_binary, and content_base64 fields. For text files, content contains the text and is_binary is False. For binary files (PDFs, images, etc.), is_binary is True and content_base64 contains the base64-encoded data |
|
||||||
| Conversation History | Full conversation history including this turn. Use to restore context on a fresh sandbox |
|
| Conversation History | Full conversation history including this turn. Use to restore context on a fresh sandbox |
|
||||||
| Session ID | Session ID for this conversation. Pass back with sandbox_id to continue the conversation |
|
| Session ID | Session ID for this conversation. Pass back with sandbox_id to continue the conversation |
|
||||||
| Sandbox ID | ID of the sandbox instance (null if disposed). Pass back with session_id to continue the conversation |
|
| Sandbox ID | ID of the sandbox instance (null if disposed). Pass back with session_id to continue the conversation |
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ When activated, the block:
|
|||||||
2. Installs the latest version of Claude Code in the sandbox
|
2. Installs the latest version of Claude Code in the sandbox
|
||||||
3. Optionally runs setup commands to prepare the environment
|
3. Optionally runs setup commands to prepare the environment
|
||||||
4. Executes your prompt using Claude Code, which can create/edit files, install dependencies, run terminal commands, and build applications
|
4. Executes your prompt using Claude Code, which can create/edit files, install dependencies, run terminal commands, and build applications
|
||||||
5. Extracts all text files created/modified during execution
|
5. Extracts all text and binary files created/modified during execution
|
||||||
6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks
|
6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks
|
||||||
|
|
||||||
The block supports conversation continuation through three mechanisms:
|
The block supports conversation continuation through three mechanisms:
|
||||||
@@ -563,7 +563,7 @@ The block supports conversation continuation through three mechanisms:
|
|||||||
|--------|-------------|------|
|
|--------|-------------|------|
|
||||||
| error | Error message if execution failed | str |
|
| error | Error message if execution failed | str |
|
||||||
| response | The output/response from Claude Code execution | str |
|
| response | The output/response from Claude Code execution | str |
|
||||||
| files | List of text files created/modified by Claude Code during this execution. Each file has 'path', 'relative_path', 'name', and 'content' fields. | List[FileOutput] |
|
| files | List of files created/modified by Claude Code during this execution. Each file has 'path', 'relative_path', 'name', 'content', 'is_binary', and 'content_base64' fields. For text files, 'content' contains the text and 'is_binary' is False. For binary files (PDFs, images, etc.), 'is_binary' is True and 'content_base64' contains the base64-encoded data. | List[FileOutput] |
|
||||||
| conversation_history | Full conversation history including this turn. Pass this to conversation_history input to continue on a fresh sandbox if the previous sandbox timed out. | str |
|
| conversation_history | Full conversation history including this turn. Pass this to conversation_history input to continue on a fresh sandbox if the previous sandbox timed out. | str |
|
||||||
| session_id | Session ID for this conversation. Pass this back along with sandbox_id to continue the conversation. | str |
|
| session_id | Session ID for this conversation. Pass this back along with sandbox_id to continue the conversation. | str |
|
||||||
| sandbox_id | ID of the sandbox instance. Pass this back along with session_id to continue the conversation. This is None if dispose_sandbox was True (sandbox was disposed). | str |
|
| sandbox_id | ID of the sandbox instance. Pass this back along with session_id to continue the conversation. This is None if dispose_sandbox was True (sandbox was disposed). | str |
|
||||||
|
|||||||
Reference in New Issue
Block a user