kosong

Kosong is an LLM abstraction layer designed for modern AI agent applications. It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you can build agents with ease and avoid vendor lock-in.

Key features:

  • kosong.generate creates a completion stream and merges streamed message parts (including content and tool calls) from any ChatProvider into a complete Message plus optional TokenUsage.
  • kosong.step layers tool dispatch (Tool, Toolset, SimpleToolset) over generate, exposing StepResult with awaited tool outputs and streaming callbacks.
  • Message structures and tool abstractions live under kosong.message and kosong.tooling.

Example:

import asyncio

from pydantic import BaseModel

import kosong
from kosong import StepResult
from kosong.chat_provider.kimi import Kimi
from kosong.message import Message
from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue
from kosong.tooling.simple import SimpleToolset


class AddToolParams(BaseModel):
    a: int
    b: int


class AddTool(CallableTool2[AddToolParams]):
    name: str = "add"
    description: str = "Add two integers."
    params: type[AddToolParams] = AddToolParams

    async def __call__(self, params: AddToolParams) -> ToolReturnValue:
        return ToolOk(output=str(params.a + params.b))


async def main() -> None:
    kimi = Kimi(
        base_url="https://api.moonshot.ai/v1",
        api_key="your_kimi_api_key_here",
        model="kimi-k2-turbo-preview",
    )

    toolset = SimpleToolset()
    toolset += AddTool()

    history = [
        Message(role="user", content="Please add 2 and 3 with the add tool."),
    ]

    result: StepResult = await kosong.step(
        chat_provider=kimi,
        system_prompt="You are a precise math tutor.",
        toolset=toolset,
        history=history,
    )
    print(result.message)
    print(await result.tool_results())


asyncio.run(main())
  1"""
  2Kosong is an LLM abstraction layer designed for modern AI agent applications.
  3It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you
  4can build agents with ease and avoid vendor lock-in.
  5
  6Key features:
  7
  8- `kosong.generate` creates a completion stream and merges streamed message parts (including
  9  content and tool calls) from any `ChatProvider` into a complete `Message` plus optional
 10  `TokenUsage`.
 11- `kosong.step` layers tool dispatch (`Tool`, `Toolset`, `SimpleToolset`) over `generate`,
 12  exposing `StepResult` with awaited tool outputs and streaming callbacks.
 13- Message structures and tool abstractions live under `kosong.message` and `kosong.tooling`.
 14
 15Example:
 16
 17```python
 18import asyncio
 19
 20from pydantic import BaseModel
 21
 22import kosong
 23from kosong import StepResult
 24from kosong.chat_provider.kimi import Kimi
 25from kosong.message import Message
 26from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue
 27from kosong.tooling.simple import SimpleToolset
 28
 29
 30class AddToolParams(BaseModel):
 31    a: int
 32    b: int
 33
 34
 35class AddTool(CallableTool2[AddToolParams]):
 36    name: str = "add"
 37    description: str = "Add two integers."
 38    params: type[AddToolParams] = AddToolParams
 39
 40    async def __call__(self, params: AddToolParams) -> ToolReturnValue:
 41        return ToolOk(output=str(params.a + params.b))
 42
 43
 44async def main() -> None:
 45    kimi = Kimi(
 46        base_url="https://api.moonshot.ai/v1",
 47        api_key="your_kimi_api_key_here",
 48        model="kimi-k2-turbo-preview",
 49    )
 50
 51    toolset = SimpleToolset()
 52    toolset += AddTool()
 53
 54    history = [
 55        Message(role="user", content="Please add 2 and 3 with the add tool."),
 56    ]
 57
 58    result: StepResult = await kosong.step(
 59        chat_provider=kimi,
 60        system_prompt="You are a precise math tutor.",
 61        toolset=toolset,
 62        history=history,
 63    )
 64    print(result.message)
 65    print(await result.tool_results())
 66
 67
 68asyncio.run(main())
 69```
 70"""
 71
 72import asyncio
 73from collections.abc import Callable, Sequence
 74from dataclasses import dataclass
 75
 76from loguru import logger
 77
 78from kosong._generate import GenerateResult, generate
 79from kosong.chat_provider import ChatProvider, ChatProviderError, StreamedMessagePart, TokenUsage
 80from kosong.message import Message, ToolCall
 81from kosong.tooling import ToolResult, ToolResultFuture, Toolset
 82from kosong.utils.aio import Callback
 83
 84# Explicitly import submodules
 85from . import chat_provider, contrib, message, tooling, utils
 86
 87logger.disable("kosong")
 88
 89__all__ = [
 90    # submodules
 91    "chat_provider",
 92    "tooling",
 93    "message",
 94    "utils",
 95    "contrib",
 96    # classes and functions
 97    "generate",
 98    "GenerateResult",
 99    "step",
100    "StepResult",
101]
102
103
104async def step(
105    chat_provider: ChatProvider,
106    system_prompt: str,
107    toolset: Toolset,
108    history: Sequence[Message],
109    *,
110    on_message_part: Callback[[StreamedMessagePart], None] | None = None,
111    on_tool_result: Callable[[ToolResult], None] | None = None,
112    on_trace_id: Callback[[str | None], None] | None = None,
113) -> "StepResult":
114    """
115    Run one agent "step". In one step, the function generates LLM response based on the given
116    context for exactly one time. All new message parts will be streamed to `on_message_part` in
117    real-time if provided. Tool calls will be handled by `toolset`. The generated message will be
118    returned in a `StepResult`. Depending on the toolset implementation, the tool calls may be
119    handled asynchronously and the results need to be fetched with `await result.tool_results()`.
120
121    The message history will NOT be modified in this function.
122
123    The token usage will be returned in the `StepResult` if available.
124
125    Raises:
126        APIConnectionError: If the API connection fails.
127        APITimeoutError: If the API request times out.
128        APIStatusError: If the API returns a status code of 4xx or 5xx.
129        APIEmptyResponseError: If the API returns an empty response.
130        ChatProviderError: If any other recognized chat provider error occurs.
131        asyncio.CancelledError: If the step is cancelled.
132    """
133
134    tool_calls: list[ToolCall] = []
135    tool_result_futures: dict[str, ToolResultFuture] = {}
136
137    def future_done_callback(future: ToolResultFuture):
138        if on_tool_result:
139            try:
140                result = future.result()
141                on_tool_result(result)
142            except asyncio.CancelledError:
143                return
144
145    async def on_tool_call(tool_call: ToolCall):
146        tool_calls.append(tool_call)
147        result = toolset.handle(tool_call)
148
149        if isinstance(result, ToolResult):
150            future = ToolResultFuture()
151            future.add_done_callback(future_done_callback)
152            future.set_result(result)
153            tool_result_futures[tool_call.id] = future
154        else:
155            result.add_done_callback(future_done_callback)
156            tool_result_futures[tool_call.id] = result
157
158    try:
159        result = await generate(
160            chat_provider,
161            system_prompt,
162            toolset.tools,
163            history,
164            on_message_part=on_message_part,
165            on_tool_call=on_tool_call,
166            on_trace_id=on_trace_id,
167        )
168    except (ChatProviderError, asyncio.CancelledError):
169        # cancel all the futures to avoid hanging tasks
170        for future in tool_result_futures.values():
171            future.remove_done_callback(future_done_callback)
172            future.cancel()
173        await asyncio.gather(*tool_result_futures.values(), return_exceptions=True)
174        raise
175
176    return StepResult(
177        result.id,
178        result.message,
179        result.usage,
180        tool_calls,
181        tool_result_futures,
182        trace_id=result.trace_id,
183    )
184
185
186@dataclass(frozen=True, slots=True)
187class StepResult:
188    id: str | None
189    """The ID of the generated message."""
190
191    message: Message
192    """The message generated in this step."""
193
194    usage: TokenUsage | None
195    """The token usage in this step."""
196
197    tool_calls: list[ToolCall]
198    """All the tool calls generated in this step."""
199
200    _tool_result_futures: dict[str, ToolResultFuture]
201    """@private The futures of the results of the spawned tool calls."""
202
203    trace_id: str | None = None
204    """The ``x-trace-id`` response header of the request, if the provider exposes it."""
205
206    async def tool_results(self) -> list[ToolResult]:
207        """All the tool results returned by corresponding tool calls."""
208        if not self._tool_result_futures:
209            return []
210
211        try:
212            results: list[ToolResult] = []
213            for tool_call in self.tool_calls:
214                future = self._tool_result_futures[tool_call.id]
215                result = await future
216                results.append(result)
217            return results
218        finally:
219            # one exception should cancel all the futures to avoid hanging tasks
220            for future in self._tool_result_futures.values():
221                future.cancel()
222            await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True)
async def generate( chat_provider: kosong.chat_provider.ChatProvider, system_prompt: str, tools: Sequence[kosong.tooling.Tool], history: Sequence[kosong.message.Message], *, on_message_part: Callback[[StreamedMessagePart], None] | None = None, on_tool_call: Callback[[kosong.message.ToolCall], None] | None = None, on_trace_id: Callback[[str | None], None] | None = None) -> GenerateResult:
 18async def generate(
 19    chat_provider: ChatProvider,
 20    system_prompt: str,
 21    tools: Sequence[Tool],
 22    history: Sequence[Message],
 23    *,
 24    on_message_part: Callback[[StreamedMessagePart], None] | None = None,
 25    on_tool_call: Callback[[ToolCall], None] | None = None,
 26    on_trace_id: Callback[[str | None], None] | None = None,
 27) -> "GenerateResult":
 28    """
 29    Generate one message based on the given context.
 30    Parts of the message will be streamed to the specified callbacks if provided.
 31
 32    Args:
 33        chat_provider: The chat provider to use for generation.
 34        system_prompt: The system prompt to use for generation.
 35        tools: The tools available for the model to call.
 36        history: The message history to use for generation.
 37        on_message_part: An optional callback to be called for each raw message part.
 38        on_tool_call: An optional callback to be called for each complete tool call.
 39        on_trace_id: An optional callback fired with the request's ``x-trace-id``
 40            response header as soon as it is available (before streaming starts).
 41
 42    Returns:
 43        A tuple of the generated message and the token usage (if available).
 44        All parts in the message are guaranteed to be complete and merged as much as possible.
 45
 46    Raises:
 47        APIConnectionError: If the API connection fails.
 48        APITimeoutError: If the API request times out.
 49        APIStatusError: If the API returns a status code of 4xx or 5xx.
 50        APIEmptyResponseError: If the API returns an empty response.
 51        ChatProviderError: If any other recognized chat provider error occurs.
 52    """
 53    message = Message(role="assistant", content=[])
 54    pending_part: StreamedMessagePart | None = None  # message part that is currently incomplete
 55
 56    logger.trace("Generating with history: {history}", history=history)
 57    stream = await chat_provider.generate(system_prompt, tools, history)
 58    if on_trace_id:
 59        # getattr for robustness against third-party StreamedMessage
 60        # implementations that predate the trace_id property.
 61        await callback(on_trace_id, getattr(stream, "trace_id", None))
 62    async for part in stream:
 63        logger.trace("Received part: {part}", part=part)
 64        if on_message_part:
 65            await callback(on_message_part, part.model_copy(deep=True))
 66
 67        if pending_part is None:
 68            pending_part = part
 69        elif not pending_part.merge_in_place(part):  # try merge into the pending part
 70            # unmergeable part must push the pending part to the buffer
 71            _message_append(message, pending_part)
 72            if isinstance(pending_part, ToolCall) and on_tool_call:
 73                await callback(on_tool_call, pending_part)
 74            pending_part = part
 75
 76    # end of message
 77    if pending_part is not None:
 78        _message_append(message, pending_part)
 79        if isinstance(pending_part, ToolCall) and on_tool_call:
 80            await callback(on_tool_call, pending_part)
 81
 82    if not message.content and not message.tool_calls:
 83        raise APIEmptyResponseError("The API returned an empty response.")
 84
 85    # A response with only ThinkPart (no TextPart, no tool calls) indicates an
 86    # abnormal termination — typically a stream interruption or max_tokens
 87    # exhaustion during reasoning.  The model should always produce visible
 88    # output after thinking; a think-only response is never intentional.
 89    has_think = any(isinstance(p, ThinkPart) for p in message.content)
 90    has_text = any(isinstance(p, TextPart) and p.text.strip() for p in message.content)
 91    if has_think and not has_text and not message.tool_calls:
 92        raise APIEmptyResponseError(
 93            "The API returned a response containing only thinking content "
 94            "without any text or tool calls. This usually indicates the "
 95            "stream was interrupted or the output token budget was exhausted "
 96            "during reasoning."
 97        )
 98
 99    return GenerateResult(
100        id=stream.id,
101        message=message,
102        usage=stream.usage,
103        trace_id=getattr(stream, "trace_id", None),
104    )

Generate one message based on the given context. Parts of the message will be streamed to the specified callbacks if provided.

Arguments:
  • chat_provider: The chat provider to use for generation.
  • system_prompt: The system prompt to use for generation.
  • tools: The tools available for the model to call.
  • history: The message history to use for generation.
  • on_message_part: An optional callback to be called for each raw message part.
  • on_tool_call: An optional callback to be called for each complete tool call.
  • on_trace_id: An optional callback fired with the request's x-trace-id response header as soon as it is available (before streaming starts).
Returns:

A tuple of the generated message and the token usage (if available). All parts in the message are guaranteed to be complete and merged as much as possible.

Raises:
  • APIConnectionError: If the API connection fails.
  • APITimeoutError: If the API request times out.
  • APIStatusError: If the API returns a status code of 4xx or 5xx.
  • APIEmptyResponseError: If the API returns an empty response.
  • ChatProviderError: If any other recognized chat provider error occurs.
@dataclass(frozen=True, slots=True)
class GenerateResult:
107@dataclass(frozen=True, slots=True)
108class GenerateResult:
109    """The result of a generation."""
110
111    id: str | None
112    """The ID of the generated message."""
113    message: Message
114    """The generated message."""
115    usage: TokenUsage | None
116    """The token usage of the generated message."""
117    trace_id: str | None = None
118    """The ``x-trace-id`` response header of the request, if the provider exposes it."""

The result of a generation.

GenerateResult( id: str | None, message: kosong.message.Message, usage: kosong.chat_provider.TokenUsage | None, trace_id: str | None = None)
id: str | None

The ID of the generated message.

The generated message.

The token usage of the generated message.

trace_id: str | None

The x-trace-id response header of the request, if the provider exposes it.

async def step( chat_provider: kosong.chat_provider.ChatProvider, system_prompt: str, toolset: kosong.tooling.Toolset, history: Sequence[kosong.message.Message], *, on_message_part: Callback[[StreamedMessagePart], None] | None = None, on_tool_result: Callable[[kosong.tooling.ToolResult], None] | None = None, on_trace_id: Callback[[str | None], None] | None = None) -> StepResult:
105async def step(
106    chat_provider: ChatProvider,
107    system_prompt: str,
108    toolset: Toolset,
109    history: Sequence[Message],
110    *,
111    on_message_part: Callback[[StreamedMessagePart], None] | None = None,
112    on_tool_result: Callable[[ToolResult], None] | None = None,
113    on_trace_id: Callback[[str | None], None] | None = None,
114) -> "StepResult":
115    """
116    Run one agent "step". In one step, the function generates LLM response based on the given
117    context for exactly one time. All new message parts will be streamed to `on_message_part` in
118    real-time if provided. Tool calls will be handled by `toolset`. The generated message will be
119    returned in a `StepResult`. Depending on the toolset implementation, the tool calls may be
120    handled asynchronously and the results need to be fetched with `await result.tool_results()`.
121
122    The message history will NOT be modified in this function.
123
124    The token usage will be returned in the `StepResult` if available.
125
126    Raises:
127        APIConnectionError: If the API connection fails.
128        APITimeoutError: If the API request times out.
129        APIStatusError: If the API returns a status code of 4xx or 5xx.
130        APIEmptyResponseError: If the API returns an empty response.
131        ChatProviderError: If any other recognized chat provider error occurs.
132        asyncio.CancelledError: If the step is cancelled.
133    """
134
135    tool_calls: list[ToolCall] = []
136    tool_result_futures: dict[str, ToolResultFuture] = {}
137
138    def future_done_callback(future: ToolResultFuture):
139        if on_tool_result:
140            try:
141                result = future.result()
142                on_tool_result(result)
143            except asyncio.CancelledError:
144                return
145
146    async def on_tool_call(tool_call: ToolCall):
147        tool_calls.append(tool_call)
148        result = toolset.handle(tool_call)
149
150        if isinstance(result, ToolResult):
151            future = ToolResultFuture()
152            future.add_done_callback(future_done_callback)
153            future.set_result(result)
154            tool_result_futures[tool_call.id] = future
155        else:
156            result.add_done_callback(future_done_callback)
157            tool_result_futures[tool_call.id] = result
158
159    try:
160        result = await generate(
161            chat_provider,
162            system_prompt,
163            toolset.tools,
164            history,
165            on_message_part=on_message_part,
166            on_tool_call=on_tool_call,
167            on_trace_id=on_trace_id,
168        )
169    except (ChatProviderError, asyncio.CancelledError):
170        # cancel all the futures to avoid hanging tasks
171        for future in tool_result_futures.values():
172            future.remove_done_callback(future_done_callback)
173            future.cancel()
174        await asyncio.gather(*tool_result_futures.values(), return_exceptions=True)
175        raise
176
177    return StepResult(
178        result.id,
179        result.message,
180        result.usage,
181        tool_calls,
182        tool_result_futures,
183        trace_id=result.trace_id,
184    )

Run one agent "step". In one step, the function generates LLM response based on the given context for exactly one time. All new message parts will be streamed to on_message_part in real-time if provided. Tool calls will be handled by toolset. The generated message will be returned in a StepResult. Depending on the toolset implementation, the tool calls may be handled asynchronously and the results need to be fetched with await result.tool_results().

The message history will NOT be modified in this function.

The token usage will be returned in the StepResult if available.

Raises:
  • APIConnectionError: If the API connection fails.
  • APITimeoutError: If the API request times out.
  • APIStatusError: If the API returns a status code of 4xx or 5xx.
  • APIEmptyResponseError: If the API returns an empty response.
  • ChatProviderError: If any other recognized chat provider error occurs.
  • asyncio.CancelledError: If the step is cancelled.
@dataclass(frozen=True, slots=True)
class StepResult:
187@dataclass(frozen=True, slots=True)
188class StepResult:
189    id: str | None
190    """The ID of the generated message."""
191
192    message: Message
193    """The message generated in this step."""
194
195    usage: TokenUsage | None
196    """The token usage in this step."""
197
198    tool_calls: list[ToolCall]
199    """All the tool calls generated in this step."""
200
201    _tool_result_futures: dict[str, ToolResultFuture]
202    """@private The futures of the results of the spawned tool calls."""
203
204    trace_id: str | None = None
205    """The ``x-trace-id`` response header of the request, if the provider exposes it."""
206
207    async def tool_results(self) -> list[ToolResult]:
208        """All the tool results returned by corresponding tool calls."""
209        if not self._tool_result_futures:
210            return []
211
212        try:
213            results: list[ToolResult] = []
214            for tool_call in self.tool_calls:
215                future = self._tool_result_futures[tool_call.id]
216                result = await future
217                results.append(result)
218            return results
219        finally:
220            # one exception should cancel all the futures to avoid hanging tasks
221            for future in self._tool_result_futures.values():
222                future.cancel()
223            await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True)
StepResult( id: str | None, message: kosong.message.Message, usage: kosong.chat_provider.TokenUsage | None, tool_calls: list[kosong.message.ToolCall], _tool_result_futures: dict[str, _asyncio.Future[kosong.tooling.ToolResult]], trace_id: str | None = None)
id: str | None

The ID of the generated message.

The message generated in this step.

The token usage in this step.

tool_calls: list[kosong.message.ToolCall]

All the tool calls generated in this step.

trace_id: str | None

The x-trace-id response header of the request, if the provider exposes it.

async def tool_results(self) -> list[kosong.tooling.ToolResult]:
207    async def tool_results(self) -> list[ToolResult]:
208        """All the tool results returned by corresponding tool calls."""
209        if not self._tool_result_futures:
210            return []
211
212        try:
213            results: list[ToolResult] = []
214            for tool_call in self.tool_calls:
215                future = self._tool_result_futures[tool_call.id]
216                result = await future
217                results.append(result)
218            return results
219        finally:
220            # one exception should cancel all the futures to avoid hanging tasks
221            for future in self._tool_result_futures.values():
222                future.cancel()
223            await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True)

All the tool results returned by corresponding tool calls.