Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions docs/decisions/00XX-python-client-agent-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-01-12
deciders: eavanvalkenburg, markwallace-microsoft, sphenry, alliscode, johanst, brettcannon
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra space in the deciders list after 'markwallace-microsoft,'. The spacing should be consistent (single space after commas throughout).

Suggested change
deciders: eavanvalkenburg, markwallace-microsoft, sphenry, alliscode, johanst, brettcannon
deciders: eavanvalkenburg, markwallace-microsoft, sphenry, alliscode, johanst, brettcannon

Copilot uses AI. Check for mistakes.
consulted: taochenosu, moonbox3, dmytrostruk, giles17
---

# Python Client and Agent Composition

## Context and Problem Statement

In Python we currently use a set of decorators that can be applied to ChatClients and Agents, those are for function calling, telemetry and middleware. However we currently do not allow a user to compose these themselves, for example to create a ChatClient that does not do function calling, but does have tools being passed to a API. Or to only have telemetry enabled on a chat client, but not on the agent. Up unto this point, that has been a sensible decision because it makes getting started very easy. However as we add more features, and more ways to customize the behavior of clients and agents, this becomes a limitation.
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two grammatical issues in this sentence: 1) "Up unto this point" should be "Up until this point" or "Up to this point", and 2) "passed to a API" should use "an" instead of "a" because "API" starts with a vowel sound.

Suggested change
In Python we currently use a set of decorators that can be applied to ChatClients and Agents, those are for function calling, telemetry and middleware. However we currently do not allow a user to compose these themselves, for example to create a ChatClient that does not do function calling, but does have tools being passed to a API. Or to only have telemetry enabled on a chat client, but not on the agent. Up unto this point, that has been a sensible decision because it makes getting started very easy. However as we add more features, and more ways to customize the behavior of clients and agents, this becomes a limitation.
In Python we currently use a set of decorators that can be applied to ChatClients and Agents, those are for function calling, telemetry and middleware. However we currently do not allow a user to compose these themselves, for example to create a ChatClient that does not do function calling, but does have tools being passed to an API. Or to only have telemetry enabled on a chat client, but not on the agent. Up until this point, that has been a sensible decision because it makes getting started very easy. However as we add more features, and more ways to customize the behavior of clients and agents, this becomes a limitation.

Copilot uses AI. Check for mistakes.

We have also seen latency issues, and every decorator adds some overhead, so being able to compose a client or agent with only the features you need would help with that as well, and it will at least make this a very explicit tradeoff. Note all the ChatClientBuilderExtensions in the C# version [here](https://github.com/dotnet/extensions/tree/main/src/Libraries/Microsoft.Extensions.AI/ChatCompletion)

## Decision Drivers

- Ease of use for new users
- Flexibility in composing client and agent features
- Maintainability of the codebase
- Performance considerations

## Considered Options

1. Current design with fixed decorators
2. Decorator based composition
3. Builder pattern with fluent API
4. Builder pattern with wrapper-based composition
5. Parameter driven composition

## Options

### Option 1: Current design with fixed decorators
Currently each ChatClient implementation and the ChatAgent class have fixed decorators applied to them. This makes it very easy for new users to get started, but it limits flexibility and can lead to performance overhead.

- Good: getting started is very easy
- Good: code is centralized and maintainable
- Good: consistent behavior across all clients
- Bad: limited flexibility in composing clients and agents
- Bad: potential performance overhead from unnecessary decorators
- Bad: users cannot opt-out of features they don't need
- Bad: becomes increasingly complex as we add more features

### Option 2: Decorator based composition
Allow users to manually apply decorators to compose their clients and agents with desired capabilities.

Example:
```python
from agent_framework import with_telemetry, with_function_calling

client = OpenAIChatClient(...)
client = with_function_calling(client)
client = with_telemetry(client, logger_factory)
```

- Good: familiar Python pattern
- Good: explicit control over which features are enabled
- Good: no new abstractions needed
- Good: users can see the exact composition order
- Good: performance optimization by only including needed decorators
- Bad: verbose and repetitive for common cases
- Bad: order of decorators matters and can be confusing
- Bad: no validation of decorator compatibility or ordering
- Bad: harder to discover available decorators and their usage

### Option 3: Builder pattern with fluent API
Use a builder class with named methods for each capability. The builder constructs clients through a pipeline pattern.

Example:
```python
client = ChatClientBuilder(OpenAIChatClient(...)) \
.with_telemetry(logger_factory) \
.with_function_calling() \
.with_capability(custom_capability) \
.build()
Comment on lines +72 to +76
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that in all these options except current one (Option 1), we will also need to decide which decorators makes sense to enable out of the box and which decorators delegate to the user for registration. For example, when using OpenAIChatClient, I think that function calling should be enabled by default. For a couple of reasons:

  • We have dedicated parameter tools through the codebase and on abstraction level, so it would be strange if it won't work because the decorator is not applied, even though the parameter is there.
  • I think OpenAI Chat Client will be used more with tools rather than without tools.

So, maybe it makes sense to use some combined approach where we decide which decorators we enable by default and which we allow users to decide (in which case we expose it based on the approach we select here).

If it makes sense, it's probably worth to update the document.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I've been thinking about doing a CodeMode function calling feature, in which case even that one would be a choice. I'll also check his dotnet handles this, because we don't want a verbose or unexpected getting started experience

```

- Good: clear and discoverable API
- Good: can validate configuration before building
- Good: follows established builder patterns, for instance for Workflows
- Good: easier to understand for new users (method names are self-documenting)
- Good: can provide sensible defaults while allowing customization
- Good: can validate ordering and either raise or adjust as needed
- Bad: all methods must be defined in core builder
- Bad: method explosion as features grow
- Bad: more verbose than current approach for simple cases
- Bad: steeper learning curve compared to current approach
- Bad: requires new builder abstraction
- Note: A generic method like `.with(wrapper)` could be added alongside named methods to enable third-party extensibility (combining advantages of Option 4), allowing both discoverable built-in methods and flexible custom wrappers

### Option 4: Builder pattern with wrapper-based composition
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer Option 3 and Option 4, and if choosing between them, I think Option 3 looks more Pythonic but wondering what other people think.

Use a builder class with a generic method (e.g., `use`) that accepts capability decorators. Each capability is implemented as a class decorator.

Example:
```python
client = ChatClientBuilder(OpenAIChatClient(...)) \
.use(TelemetryWrapper(logger_factory)) \
.use(FunctionCallingWrapper(...)) \
.use(custom_wrapper) \
.build()
Comment on lines +98 to +101
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use method accept ChatClient implementation instead of wrapper, so we can have FunctionInvokingChatClient, TelemetryChatClient and so on? With this approach, users will operate with chat clients only without a need to learn about new wrapper abstraction.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can name the wrappers with names like that? Or is that not what you mean?

```

- Good: very flexible and extensible by third parties
- Good: clear separation between core client and capabilities
- Good: can validate some configuration before building
- Good: supports both simple and complex use cases
- Good: easier to test individual capabilities
- Good: third parties can create their own wrapper classes without modifying core
- Bad: more verbose than current approach for simple cases
- Bad: less discoverable than fluent methods (need to know wrapper class names)
- Bad: steeper learning curve for new users
- Bad: requires new builder abstraction and wrapper classes
- Bad: wrapper objects add another layer of abstraction

### Option 5: Parameter driven composition
Add parameters to the client/agent constructors to control which features are enabled.

Example:
```python
client = OpenAIChatClient(
...,
enable_telemetry=True,
enable_function_calling=False,
middleware=[custom_middleware1, custom_middleware2]
)
```

- Good: simple and intuitive API
- Good: easy to understand for new users
- Good: works well for binary enable/disable flags
- Good: configuration can be loaded from files/environment
- Good: still relatively easy to get started
- Bad: can lead to many constructor parameters as features grow
- Bad: less flexible for custom middleware with complex configuration
- Bad: parameter explosion problem (each feature needs its own parameter)
- Bad: depending on the setup, might still have overhead from unused features

## Decision Outcome
TBD