Skip to content

Support Recursive Pydantic Objects with Bedrock API #824

@afarntrog

Description

@afarntrog

So just had a conversation with Mackenzie in the below I'll outline what the current behavior is with our code, the bedrock api and what solutions we have.

The following recursive reference will fail in our current code with a ValueError due to the circular reference

class User(BaseModel):
    id: int
    name: str
    friend: Optional['User'] = None

agent = Agent(
    system_prompt="You excel at structured output"
    )
circ = agent.structured_output(User , "John Smith is a 30-year-old software engineer with id of 3 and has a friend named jack with id of 9")
circ

The same code will fail if we remove our circular reference check and call bedrock with the actual Pydantic schema. The following is the pydantic schema which we'll send to bedrock directly:

{
    "$defs": {
        "User": {
            "properties": {
                "id": {"title": "Id", "type": "integer"},
                "name": {"title": "Name", "type": "string"},
                "friend": {
                    "anyOf": [{"$ref": "#/$defs/User"}, {"type": "null"}],
                    "default": None,
                },
            },
            "required": ["id", "name"],
            "title": "User",
            "type": "object",
        }
    },
    "$ref": "#/$defs/User",
}

Bedrock Error:

---------------------------------------------------------------------------
ValidationException                       Traceback (most recent call last)
Cell In[4], [line 9](vscode-notebook-cell:?execution_count=4&line=9)
      4     friend: Optional['User'] = None
      6 agent = Agent(
      7     system_prompt="You excel at structured output"
      8     )
----> [9](vscode-notebook-cell:?execution_count=4&line=9) circ = agent.structured_output(User , "John Smith is a 30-year-old software engineer with id of 3 and has a friend named jack with id of 9")
     10 circ

File /Volumes/workplace/dev/structured_output/sdk-python/src/strands/agent/agent.py:468, in Agent.structured_output(self, output_model, prompt)
    466 with ThreadPoolExecutor() as executor:
    467     future = executor.submit(execute)
--> [468](https://file+.vscode-resource.vscode-cdn.net/Volumes/workplace/dev/structured_output/sdk-python/src/strands/agent/agent.py:468)     return future.result()

File ~/.local/share/uv/python/cpython-3.10.18-macos-aarch64-none/lib/python3.10/concurrent/futures/_base.py:458, in Future.result(self, timeout)
    456     raise CancelledError()
    457 elif self._state == FINISHED:
--> [458](https://file+.vscode-resource.vscode-cdn.net/Volumes/workplace/dev/structured_output/sdk-python/afarnsandbox/695/~/.local/share/uv/python/cpython-3.10.18-macos-aarch64-none/lib/python3.10/concurrent/futures/_base.py:458)     return self.__get_result()
    459 else:
    460     raise TimeoutError()

File ~/.local/share/uv/python/cpython-3.10.18-macos-aarch64-none/lib/python3.10/concurrent/futures/_base.py:403, in Future.__get_result(self)
    401 if self._exception:
    402     try:
...
-> [1078](https://file+.vscode-resource.vscode-cdn.net/Volumes/workplace/dev/structured_output/sdk-python/.venv/lib/python3.10/site-packages/botocore/client.py:1078)     raise error_class(parsed_response, operation_name)
   1079 else:
   1080     return parsed_response

ValidationException: An error occurred (ValidationException) when calling the ConverseStream operation: The value at toolConfig.tools.0.toolSpec.inputSchema.json.type must be one of the following: object.

However, interestingly enough, if we were to move that recursive model one level down into another model then Bedrock will not throw an error and instead will correctly return a result that conforms to the pydantic schema:

class User(BaseModel):
    id: int
    name: str
    friend: Optional['User'] = None

class NestedCircular(BaseModel):
    leave_blank: str = Field(description="A field that should be left blank", default='just a field')
    child: User = Field(description="A child user")

agent = Agent(
    system_prompt="You excel at structured output"
    )
circ = agent.structured_output(NestedCircular, "John Smith is a 30-year-old software engineer with id of 3 and has a friend named jack with id of 9")
circ

and then model.model_json_schema() will yield:

{
    "$defs": {
        "User": {
            "properties": {
                "id": {"title": "Id", "type": "integer"},
                "name": {"title": "Name", "type": "string"},
                "friend": {
                    "anyOf": [{"$ref": "#/$defs/User"}, {"type": "null"}],
                    "default": None,
                },
            },
            "required": ["id", "name"],
            "title": "User",
            "type": "object",
        }
    },
    "properties": {
        "leave_blank": {
            "default": "just a field",
            "description": "A field that should be left blank",
            "title": "Leave Blank",
            "type": "string",
        },
        "child": {"type": "object", "description": "A child user", "properties": {}},
    },
    "required": ["child"],
    "title": "NestedCircular",
    "type": "object",
}

which the model handles correctly.

Note: that as of the current main branch code, the above object will not be detected, no error will be thrown, however, we try to do some incomplete ref parsing which sends Bedrock an incomplete json schema which results in incomplete Model results which results in a pydantic error when we try to put the bedrock results into the Pydantic object.

Mackenzie suggested a possible approach we can investigate in a new issue where we can:

  1. use the native pydantic json and send that directly to the model. as we can see it works when there is a recursive pydantic object as long as it's not the top level object.

  2. for the case where it's a top level object we can attempt to add the actual json schema to the payload when sending it to bedrock so that the top level payload doesn't just have a ref... instead it will have the ref data so that the model can then treat it just like the case where the recursive pydantic object is nested inside another pydantic object

Originally posted by @afarntrog in #817 (comment)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions