diff --git a/Cargo.toml b/Cargo.toml index 52babf90af..0e77e05aef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ tracing-appender = "0.2.3" time-tz = "2.0.0" num-traits = "0.2.19" reqwest = { version = "0.12.23", default-features = false } +eventsource-stream = "0.2.3" parking_lot = "0.12.4" hmac = "0.12.1" sha1 = "0.10.6" diff --git a/c/cbindgen.toml b/c/cbindgen.toml index 2b42f49a91..66fb326db2 100644 --- a/c/cbindgen.toml +++ b/c/cbindgen.toml @@ -332,6 +332,47 @@ cpp_compat = true "CScreenerStrategyResponse" = "lb_screener_strategy_response_t" "CScreenerSearchResponse" = "lb_screener_search_response_t" "CScreenerIndicatorsResponse" = "lb_screener_indicators_response_t" +# AgentContext +"CAgentContext" = "lb_agent_context_t" +"COnConversationEventCallback" = "lb_conversation_event_callback_t" +"CConversationStatus" = "lb_conversation_status_t" +"CConversationStreamEventType" = "lb_conversation_stream_event_type_t" +"CWorkspace" = "lb_workspace_t" +"CWorkspacesResponse" = "lb_workspaces_response_t" +"CAgent" = "lb_agent_t" +"CAgentsResponse" = "lb_agents_response_t" +"CGetAgentsOptions" = "lb_get_agents_options_t" +"CReference" = "lb_reference_t" +"CQuestionOption" = "lb_question_option_t" +"CQuestion" = "lb_question_t" +"CInterrupt" = "lb_interrupt_t" +"CAgentError" = "lb_agent_error_t" +"CConversationResponse" = "lb_conversation_response_t" +"CChatStartedPayload" = "lb_chat_started_payload_t" +"CWorkflowStartedInputs" = "lb_workflow_started_inputs_t" +"CWorkflowStartedPayload" = "lb_workflow_started_payload_t" +"CMessagePayload" = "lb_message_payload_t" +"CThinkingStartedPayload" = "lb_thinking_started_payload_t" +"CThinkingFinishedPayload" = "lb_thinking_finished_payload_t" +"CNodeToolUseStartedPayload" = "lb_node_tool_use_started_payload_t" +"CNodeToolUseOutputs" = "lb_node_tool_use_outputs_t" +"CNodeToolUseFinishedPayload" = "lb_node_tool_use_finished_payload_t" +"CSubagentStartedPayload" = "lb_subagent_started_payload_t" +"CSubagentProgressPayload" = "lb_subagent_progress_payload_t" +"CSubagentOutputs" = "lb_subagent_outputs_t" +"CSubagentFinishedPayload" = "lb_subagent_finished_payload_t" +"CAgentToolStartedPayload" = "lb_agent_tool_started_payload_t" +"CAgentToolProgressPayload" = "lb_agent_tool_progress_payload_t" +"CAgentToolFinishedPayload" = "lb_agent_tool_finished_payload_t" +"CQueryMaskedPayload" = "lb_query_masked_payload_t" +"CPlanChangedPayload" = "lb_plan_changed_payload_t" +"CContextCompressStartedPayload" = "lb_context_compress_started_payload_t" +"CContextCompressFinishedPayload" = "lb_context_compress_finished_payload_t" +"CChatFinishedPayload" = "lb_chat_finished_payload_t" +"CChatTitleUpdatedPayload" = "lb_chat_title_updated_payload_t" +"CConversationStreamEvent" = "lb_conversation_stream_event_t" +"CAnswerQuestion" = "lb_answer_question_t" +"CAnswersByToolCallEntry" = "lb_answers_by_tool_call_entry_t" [export] include = [ @@ -464,4 +505,8 @@ include = [ "CScreenerContext", "CScreenerRecommendStrategiesResponse", "CScreenerUserStrategiesResponse", "CScreenerStrategyResponse", "CScreenerSearchResponse", "CScreenerIndicatorsResponse", + # AgentContext: only reachable via the type-erased `void*` completion + # callback data pointer, not from any genuinely-typed function signature, + # so cbindgen can't discover them transitively — force their emission. + "CWorkspace", "CWorkspacesResponse", "CAgent", "CAgentsResponse", ] diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index f0b71ea527..f82917deeb 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -46,6 +46,153 @@ */ #define LB_WATCHLIST_GROUP_SECURITIES 2 +/** + * Kind of a [`crate::agent_context::types::CConversationStreamEvent`]. Only + * the field matching this kind is non-null, all others are null. + */ +typedef enum lb_conversation_stream_event_type_t { + /** + * The run has started; `chat_started` is non-null + */ + ChatStarted, + /** + * Observed right after `ChatStarted` on every run seen so far; + * `workflow_started` is non-null + */ + WorkflowStarted, + /** + * An incremental piece of the answer; `message` is non-null + */ + Message, + /** + * A heartbeat with no payload, observed at arbitrary points in the + * stream (including in between `Message` chunks); every field below is + * null + */ + Ping, + /** + * The Agent has entered the reasoning phase; `thinking_started` is + * non-null + */ + ThinkingStarted, + /** + * The reasoning phase is over; `thinking_finished` is non-null + */ + ThinkingFinished, + /** + * An ordinary tool call has started; `node_tool_use_started` is + * non-null + */ + NodeToolUseStarted, + /** + * An ordinary tool call has ended; `node_tool_use_finished` is + * non-null + */ + NodeToolUseFinished, + /** + * The Agent has spawned a subagent to work on a sub-task; + * `subagent_started` is non-null + */ + SubagentStarted, + /** + * The subagent has called one of its own tools; `subagent_progress` is + * non-null + */ + SubagentProgress, + /** + * The subagent has finished its sub-task; `subagent_finished` is + * non-null + */ + SubagentFinished, + /** + * The Agent has delegated to another Agent as a tool; + * `agent_tool_started` is non-null + */ + AgentToolStarted, + /** + * The delegated Agent has called one of its own tools; + * `agent_tool_progress` is non-null + */ + AgentToolProgress, + /** + * The delegated Agent's run has finished; `agent_tool_finished` is + * non-null + */ + AgentToolFinished, + /** + * The run is paused: the Agent needs more information or confirmation + * from you; `human_interaction_required` is non-null. Unlike + * `WorkflowFinished`, this is emitted instead of (never alongside) + * `WorkflowFinished` for the same run + */ + HumanInteractionRequired, + /** + * Sensitive content in the user query was masked before processing; + * `query_masked` is non-null + */ + QueryMasked, + /** + * The Agent created or updated its task plan; `plan_changed` is + * non-null + */ + PlanChanged, + /** + * A context-compression pass has started; `context_compress_started` + * is non-null + */ + ContextCompressStarted, + /** + * The context-compression pass has finished; + * `context_compress_finished` is non-null + */ + ContextCompressFinished, + /** + * Observed once all `Message` events for this round have been sent; + * `chat_finished` is non-null + */ + ChatFinished, + /** + * The run finished successfully, with a failure, or stopped by the + * user; `workflow_finished` is non-null. Never emitted for an + * interrupted run — see `HumanInteractionRequired` for that case + */ + WorkflowFinished, + /** + * The server auto-generating a short title for the conversation; + * `chat_title_updated` is non-null + */ + ChatTitleUpdated, + /** + * An event type not recognized by this SDK version; `other_json` is + * non-null and contains the raw event JSON + */ + Other, +} lb_conversation_stream_event_type_t; + +/** + * Final run status of a conversation + */ +typedef enum lb_conversation_status_t { + /** + * The run completed successfully + */ + ConversationStatusSucceeded, + /** + * The run is paused, waiting for + * `lb_agent_context_continue_conversation`/ + * `lb_agent_context_continue_conversation_streamed` + */ + ConversationStatusInterrupted, + /** + * The run failed + */ + ConversationStatusFailed, + /** + * The run was stopped + */ + ConversationStatusStopped, +} lb_conversation_status_t; + /** * Alert trigger condition */ @@ -1461,217 +1608,1279 @@ typedef enum lb_institution_recommend_t { */ InstitutionRecommendBuy, /** - * Hold + * Hold + */ + InstitutionRecommendHold, + /** + * Sell + */ + InstitutionRecommendSell, + /** + * Strong sell + */ + InstitutionRecommendStrongSell, + /** + * Underperform + */ + InstitutionRecommendUnderperform, + /** + * No opinion + */ + InstitutionRecommendNoOpinion, +} lb_institution_recommend_t; + +/** + * DCA plan status + */ +typedef enum lb_dca_status_t { + /** + * Plan is active + */ + DcaStatusActive, + /** + * Plan has been paused + */ + DcaStatusSuspended, + /** + * Plan has finished + */ + DcaStatusFinished, +} lb_dca_status_t; + +/** + * Financial report period + */ +typedef enum lb_financial_report_period_t { + /** + * Annual report + */ + FinancialReportPeriodAnnual, + /** + * Semi-annual report + */ + FinancialReportPeriodSemiAnnual, + /** + * Q1 report + */ + FinancialReportPeriodQ1, + /** + * Q2 report + */ + FinancialReportPeriodQ2, + /** + * Q3 report + */ + FinancialReportPeriodQ3, + /** + * Full quarterly report + */ + FinancialReportPeriodQuarterlyFull, + /** + * Three-quarter report (first three quarters) + */ + FinancialReportPeriodThreeQ, +} lb_financial_report_period_t; + +/** + * Flow direction + */ +typedef enum lb_flow_direction_t { + /** + * Unknown direction + */ + FlowDirectionUnknown, + /** + * Buy + */ + FlowDirectionBuy, + /** + * Sell + */ + FlowDirectionSell, +} lb_flow_direction_t; + +/** + * Asset type + */ +typedef enum lb_asset_type_t { + /** + * Unknown type + */ + AssetTypeUnknown, + /** + * Stock + */ + AssetTypeStock, + /** + * Fund + */ + AssetTypeFund, + /** + * Crypto + */ + AssetTypeCrypto, +} lb_asset_type_t; + +/** + * ETF asset allocation element type + */ +typedef enum lb_element_type_t { + /** + * Unknown + */ + ElementTypeUnknown, + /** + * Holdings + */ + ElementTypeHoldings, + /** + * Regional + */ + ElementTypeRegional, + /** + * Asset class + */ + ElementTypeAssetClass, + /** + * Industry + */ + ElementTypeIndustry, +} lb_element_type_t; + +/** + * AI Agent conversation context + */ +typedef struct lb_agent_context_t lb_agent_context_t; + +typedef struct lb_alert_context_t lb_alert_context_t; + +/** + * Asset context + */ +typedef struct CAssetContext CAssetContext; + +typedef struct lb_calendar_context_t lb_calendar_context_t; + +/** + * Configuration options for Longbridge SDK + */ +typedef struct lb_config_t lb_config_t; + +/** + * Content context + */ +typedef struct lb_content_context_t lb_content_context_t; + +typedef struct lb_dca_context_t lb_dca_context_t; + +typedef struct lb_decimal_t lb_decimal_t; + +typedef struct lb_error_t lb_error_t; + +typedef struct lb_fundamental_context_t lb_fundamental_context_t; + +/** + * A HTTP client for Longbridge OpenAPI + */ +typedef struct lb_http_client_t lb_http_client_t; + +typedef struct lb_http_result_t lb_http_result_t; + +/** + * Market data context + */ +typedef struct lb_market_context_t lb_market_context_t; + +/** + * OAuth 2.0 client — owns the Rust `OAuth` instance (opaque handle) + * + * Callers must never dereference or inspect the struct layout. + * Always free with `lb_oauth_free`. + */ +typedef struct lb_oauth_t lb_oauth_t; + +typedef struct lb_portfolio_context_t lb_portfolio_context_t; + +/** + * Quote context + */ +typedef struct lb_quote_context_t lb_quote_context_t; + +typedef struct lb_screener_context_t lb_screener_context_t; + +typedef struct lb_sharelist_context_t lb_sharelist_context_t; + +/** + * Trade context + */ +typedef struct lb_trade_context_t lb_trade_context_t; + +typedef struct lb_async_result_t { + const void *ctx; + const struct lb_error_t *error; + void *data; + uintptr_t length; + void *userdata; +} lb_async_result_t; + +typedef void (*lb_async_callback_t)(const struct lb_async_result_t*); + +/** + * Options for `lb_agent_context_agents` (all fields can be null) + */ +typedef struct lb_get_agents_options_t { + /** + * Page number, starts at 1 (can be null) + */ + const int32_t *page; + /** + * Page size (can be null) + */ + const int32_t *limit; + /** + * Fuzzy search by Agent name (can be null) + */ + const char *name; +} lb_get_agents_options_t; + +/** + * One answer to a [`CInterrupt`] question, used as an entry of + * [`CAnswersByToolCallEntry::answers`] + */ +typedef struct lb_answer_question_t { + /** + * Question text, must match `CQuestion::question` verbatim + */ + const char *question; + /** + * Your answer text + */ + const char *answer; +} lb_answer_question_t; + +/** + * Answers for one `tool_call_id`, used as an entry of the `answers` array of + * `lb_agent_context_continue_conversation`/ + * `lb_agent_context_continue_conversation_streamed`. + * + * The Rust core's `AnswersByToolCall` is a + * `HashMap>` keyed by `tool_call_id`, then by + * question text. Since C has no native map type, it's flattened into an + * array of `(tool_call_id, [(question, answer)])` entries — this array of + * `CAnswersByToolCallEntry` mirrors the outer map, and each entry's + * `answers` array (of `CAnswerQuestion`) mirrors the inner map. + */ +typedef struct lb_answers_by_tool_call_entry_t { + /** + * Tool call ID, see [`CInterrupt::tool_call_id`] + */ + const char *tool_call_id; + /** + * Answers to the questions raised for this tool call + */ + const struct lb_answer_question_t *answers; + /** + * Number of answers + */ + uintptr_t num_answers; +} lb_answers_by_tool_call_entry_t; + +/** + * Payload of a `ChatStarted` conversation stream event + */ +typedef struct lb_chat_started_payload_t { + /** + * Conversation identifier + */ + const char *chat_uid; + /** + * Message ID of this round + */ + const char *message_id; +} lb_chat_started_payload_t; + +/** + * `inputs` of a `WorkflowStarted` conversation stream event + */ +typedef struct lb_workflow_started_inputs_t { + /** + * ID of the owning conversation + */ + int64_t chat_id; + /** + * Conversation identifier + */ + const char *chat_uid; + /** + * Message ID of this round + */ + const char *message_id; + /** + * The question that was asked + */ + const char *query; +} lb_workflow_started_inputs_t; + +/** + * Payload of a `WorkflowStarted` conversation stream event, observed right + * after `ChatStarted` on every run seen so far + */ +typedef struct lb_workflow_started_payload_t { + /** + * Whether this run's answer was served from a cache + */ + bool hit_cache; + /** + * Echoes the run's inputs + */ + const struct lb_workflow_started_inputs_t *inputs; + /** + * Unix timestamp in seconds + */ + int64_t started_at; + /** + * Internal workflow run ID + */ + int64_t workflow_id; +} lb_workflow_started_payload_t; + +/** + * Payload of a `Message` conversation stream event — an incremental text + * chunk. This is the highest-frequency event; concatenate `text` fragments + * in arrival order. + */ +typedef struct lb_message_payload_t { + /** + * Incremental text fragment + */ + const char *text; + /** + * `answer` — final answer text; `think` — reasoning process; `process` + * — stage progress description + */ + const char *message_type; + /** + * Identifier of the stream segment this fragment belongs to. Fragments + * with the same `key` form one continuous block — group by `key` when + * rendering + */ + const char *key; + /** + * Time this segment started, Unix timestamp in seconds + */ + int64_t started_at; + /** + * Stage identifier; only present when `message_type` is `"process"` + */ + const char *stage; + /** + * Stage title while running; only present when `message_type` is + * `"process"` + */ + const char *stage_title; + /** + * Stage title after it finishes; only present when `message_type` is + * `"process"` + */ + const char *stage_finished_title; + /** + * Extra payload attached to the fragment, as a JSON string; empty when + * absent + */ + const char *outputs_json; +} lb_message_payload_t; + +/** + * Payload of a `ThinkingStarted` conversation stream event — the Agent has + * entered the reasoning phase (analyzing the question, planning tool + * calls). Between this and `ThinkingFinished`, `Message` events with + * `message_type == "think"` and tool-call events may arrive. + */ +typedef struct lb_thinking_started_payload_t { + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; +} lb_thinking_started_payload_t; + +/** + * Payload of a `ThinkingFinished` conversation stream event — the + * reasoning phase is over; answer text (`Message` with `message_type == + * "answer"`) follows. + */ +typedef struct lb_thinking_finished_payload_t { + /** + * Finish time, Unix timestamp in seconds + */ + int64_t finished_at; + /** + * Reasoning duration in seconds + */ + int32_t elapsed_time; +} lb_thinking_finished_payload_t; + +/** + * Payload of a `NodeToolUseStarted` conversation stream event — an + * ordinary tool call has started. Match it to its `NodeToolUseFinished` + * counterpart by `tool_use_id`. + */ +typedef struct lb_node_tool_use_started_payload_t { + /** + * Unique ID of this call; matches the finished event + */ + const char *tool_use_id; + /** + * Localized display name of the tool + */ + const char *tool_name; + /** + * Locale-stable tool identifier; use this for logic keyed on the tool + * kind + */ + const char *tool_func_name; + /** + * Call arguments as a JSON string + */ + const char *tool_args; + /** + * Progress text suitable for direct display, e.g. `"Searching the + * web…"` + */ + const char *tips; + /** + * Short tags accompanying `tips`; may be empty + */ + const char *const *tip_chips; + /** + * Number of tags in `tip_chips` + */ + uintptr_t num_tip_chips; + /** + * Round number. Calls in the same round (same `iteration`) run in + * parallel + */ + int32_t iteration; + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; +} lb_node_tool_use_started_payload_t; + +/** + * A source referenced by the answer + */ +typedef struct lb_reference_t { + /** + * Reference index + */ + int32_t index; + /** + * Reference title + */ + const char *title; + /** + * Reference URL + */ + const char *url; +} lb_reference_t; + +/** + * `outputs` of a `NodeToolUseFinished` conversation stream event — only + * carries fields meant for display + */ +typedef struct lb_node_tool_use_outputs_t { + /** + * Sources referenced by the tool result + */ + const struct lb_reference_t *references; + /** + * Number of references + */ + uintptr_t num_references; + /** + * Domains of the referenced sources + */ + const char *const *reference_domains; + /** + * Number of reference domains + */ + uintptr_t num_reference_domains; + /** + * The query the tool executed; empty when absent + */ + const char *query; + /** + * Raw response text of the tool; empty when absent + */ + const char *text; + /** + * Parsed request arguments, as a JSON string; empty when absent + */ + const char *tool_args_json; + /** + * Structured result, as a JSON string; present only for selected + * tools, empty when absent + */ + const char *data_json; +} lb_node_tool_use_outputs_t; + +/** + * Payload of a `NodeToolUseFinished` conversation stream event — the tool + * call has ended. + */ +typedef struct lb_node_tool_use_finished_payload_t { + /** + * Matches the `tool_use_id` of the started event + */ + const char *tool_use_id; + /** + * `succeeded` / `failed` + */ + const char *status; + /** + * Error description on failure + */ + const char *error; + /** + * Call duration in seconds + */ + double elapsed_time; + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; + /** + * Localized display name + */ + const char *tool_name; + /** + * Locale-stable tool identifier + */ + const char *tool_func_name; + /** + * Call arguments as a JSON string + */ + const char *tool_args; + /** + * Tool category + */ + const char *tool_type; + /** + * Progress text + */ + const char *tips; + /** + * Short tags; may be empty + */ + const char *const *tip_chips; + /** + * Number of tags in `tip_chips` + */ + uintptr_t num_tip_chips; + /** + * Round number + */ + int32_t iteration; + /** + * `true` if the call happened during the thinking phase + */ + bool is_thinking; + /** + * Filtered call results, for display + */ + const struct lb_node_tool_use_outputs_t *outputs; +} lb_node_tool_use_finished_payload_t; + +/** + * Payload of a `SubagentStarted` conversation stream event. When the Agent + * spawns a subagent to work on a sub-task, the subagent's lifecycle is + * reported with this dedicated event family instead of `NodeToolUse*`. + */ +typedef struct lb_subagent_started_payload_t { + /** + * ID of the node that spawned the subagent + */ + const char *node_id; + /** + * Unique ID of this spawn; matches the finished event + */ + const char *tool_use_id; + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; + /** + * Goal assigned to the subagent + */ + const char *goal; + /** + * Full task prompt given to the subagent + */ + const char *prompt; + /** + * Subagent identifier; may be empty + */ + const char *subagent_id; + /** + * Tools granted to the subagent, as a JSON array string; empty when + * absent + */ + const char *tools_json; +} lb_subagent_started_payload_t; + +/** + * Payload of a `SubagentProgress` conversation stream event, emitted every + * time the subagent calls one of its own tools. Use it to render a live + * timeline inside the subagent card. + */ +typedef struct lb_subagent_progress_payload_t { + /** + * ID of the node that spawned the subagent + */ + const char *node_id; + /** + * `tool_use_id` of the owning `SubagentStarted` event + */ + const char *parent_tool_call_id; + /** + * Name of the tool the subagent called + */ + const char *subagent_tool_name; + /** + * Arguments of that call, as a JSON string + */ + const char *subagent_tool_args; + /** + * Status of that call: `running` / `succeeded` / `failed` + */ + const char *subagent_status; + /** + * Duration of that call in milliseconds + */ + int64_t subagent_duration_ms; + /** + * The subagent's internal round number + */ + int32_t subagent_iteration; + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; +} lb_subagent_progress_payload_t; + +/** + * `outputs` of a `SubagentFinished` conversation stream event + */ +typedef struct lb_subagent_outputs_t { + /** + * The goal that was assigned to the subagent; empty when absent + */ + const char *goal; + /** + * The subagent's result; empty when absent + */ + const char *result; + /** + * Timeline of tool calls the subagent made, as a JSON array string; + * empty when absent + */ + const char *subagent_tools_json; +} lb_subagent_outputs_t; + +/** + * Payload of a `SubagentFinished` conversation stream event + */ +typedef struct lb_subagent_finished_payload_t { + /** + * ID of the node that spawned the subagent + */ + const char *node_id; + /** + * Matches the `tool_use_id` of `SubagentStarted` + */ + const char *tool_use_id; + /** + * `succeeded` / `failed` + */ + const char *status; + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; + /** + * Total subagent duration in seconds + */ + double elapsed_time; + /** + * Error description on failure + */ + const char *error; + /** + * Subagent result: `goal`, `result`, and the timeline of tool calls it + * made + */ + const struct lb_subagent_outputs_t *outputs; +} lb_subagent_finished_payload_t; + +/** + * Payload of an `AgentToolStarted` conversation stream event. When the + * Agent delegates to another Agent as a tool, that inner run is reported + * with the `AgentTool*` family — the shape mirrors the subagent events. + */ +typedef struct lb_agent_tool_started_payload_t { + /** + * ID of the calling node + */ + const char *node_id; + /** + * Unique ID of this call; matches the finished event + */ + const char *tool_use_id; + /** + * Identifier of the Agent being called + */ + const char *agent_tool_name; + /** + * Display title; may be empty + */ + const char *title; + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; + /** + * Call arguments as a JSON string + */ + const char *tool_args; + /** + * Localized display name + */ + const char *tool_name; + /** + * Progress text; may be empty + */ + const char *tips; + /** + * Short tags; may be empty + */ + const char *const *tip_chips; + /** + * Number of tags in `tip_chips` + */ + uintptr_t num_tip_chips; + /** + * `true` if called during the thinking phase + */ + bool is_thinking; +} lb_agent_tool_started_payload_t; + +/** + * Payload of an `AgentToolProgress` conversation stream event, emitted for + * each inner tool call the delegated Agent makes. + */ +typedef struct lb_agent_tool_progress_payload_t { + /** + * ID of the calling node + */ + const char *node_id; + /** + * `tool_use_id` of the owning `AgentToolStarted` event + */ + const char *parent_tool_call_id; + /** + * Identifier of the Agent being called + */ + const char *agent_tool_name; + /** + * Name of the inner tool the delegated Agent called + */ + const char *inner_tool_name; + /** + * Arguments of that inner call, as a JSON string + */ + const char *inner_tool_args; + /** + * Status of the inner call: `running` / `succeeded` / `failed` + */ + const char *status; + /** + * Duration of the inner call in milliseconds + */ + int64_t duration_ms; + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; + /** + * `true` if during the thinking phase + */ + bool is_thinking; +} lb_agent_tool_progress_payload_t; + +/** + * Payload of an `AgentToolFinished` conversation stream event + */ +typedef struct lb_agent_tool_finished_payload_t { + /** + * ID of the calling node + */ + const char *node_id; + /** + * Matches the `tool_use_id` of `AgentToolStarted` + */ + const char *tool_use_id; + /** + * Identifier of the Agent being called + */ + const char *agent_tool_name; + /** + * `succeeded` / `failed` + */ + const char *status; + /** + * Start time, Unix timestamp in seconds + */ + int64_t started_at; + /** + * Total duration in seconds + */ + double elapsed_time; + /** + * Error description on failure + */ + const char *error; + /** + * Call arguments as a JSON string + */ + const char *tool_args; + /** + * Result of the delegated Agent, as a JSON string; empty when absent + */ + const char *outputs_json; + /** + * Tool category + */ + const char *tool_type; + /** + * Progress text; may be empty + */ + const char *tips; + /** + * Short tags; may be empty + */ + const char *const *tip_chips; + /** + * Number of tags in `tip_chips` + */ + uintptr_t num_tip_chips; + /** + * `true` if during the thinking phase + */ + bool is_thinking; +} lb_agent_tool_finished_payload_t; + +/** + * One option of a [`CQuestion`] + */ +typedef struct lb_question_option_t { + /** + * Option text + */ + const char *description; +} lb_question_option_t; + +/** + * One question the Agent needs you to answer + */ +typedef struct lb_question_t { + /** + * Question text + */ + const char *question; + /** + * Options; empty means free-form answer + */ + const struct lb_question_option_t *options; + /** + * Number of options + */ + uintptr_t num_options; + /** + * Whether multiple options may be selected + */ + bool multi_select; +} lb_question_t; + +/** + * Present when a conversation run is interrupted, waiting for + * `lb_agent_context_continue_conversation` + */ +typedef struct lb_interrupt_t { + /** + * ID of the node that triggered the interrupt + */ + const char *node_id; + /** + * Tool call ID of this inquiry; used as the answer key when continuing + */ + const char *tool_call_id; + /** + * Questions you need to answer + */ + const struct lb_question_t *questions; + /** + * Number of questions + */ + uintptr_t num_questions; + /** + * ID of the paused message + */ + int64_t message_id; + /** + * ID of the owning conversation + */ + int64_t chat_id; +} lb_interrupt_t; + +/** + * Present when a conversation run failed + */ +typedef struct lb_agent_error_t { + /** + * Error code + */ + int32_t code; + /** + * Error message + */ + const char *message; +} lb_agent_error_t; + +/** + * Response for `lb_agent_context_conversation`, + * `lb_agent_context_continue_conversation`, and the final result of the + * streamed counterparts + */ +typedef struct lb_conversation_response_t { + /** + * Conversation identifier, used for follow-up questions and + * troubleshooting + */ + const char *chat_uid; + /** + * Message ID of this round + */ + const char *message_id; + /** + * Final run status + */ + enum lb_conversation_status_t status; + /** + * Final answer text; valid when `status` is + * `ConversationStatusSucceeded` + */ + const char *answer; + /** + * Sources referenced by the answer + */ + const struct lb_reference_t *references; + /** + * Number of references + */ + uintptr_t num_references; + /** + * Run duration in seconds + */ + double elapsed_time; + /** + * Present only when `status` is `ConversationStatusInterrupted` (can be + * null) + */ + const struct lb_interrupt_t *interrupt; + /** + * Present only when the run failed (can be null) + */ + const struct lb_agent_error_t *error; +} lb_conversation_response_t; + +/** + * Payload of a `QueryMasked` conversation stream event — sensitive content + * in the user query was masked before processing. Display `masked_query` + * instead of the original query. + */ +typedef struct lb_query_masked_payload_t { + /** + * The original user query + */ + const char *raw_query; + /** + * The masked query + */ + const char *masked_query; +} lb_query_masked_payload_t; + +/** + * Payload of a `PlanChanged` conversation stream event — the Agent created + * or updated its task plan. + */ +typedef struct lb_plan_changed_payload_t { + /** + * ID of the planning node + */ + const char *node_id; + /** + * Time of the change, Unix timestamp in seconds + */ + int64_t started_at; + /** + * The current plan content, as a JSON string; empty when absent + */ + const char *outputs_json; + /** + * Identifies the planning tool + */ + const char *tool_name; +} lb_plan_changed_payload_t; + +/** + * Payload of a `ContextCompressStarted` conversation stream event, marking + * the start of a context-compression pass triggered by a long + * conversation. Unlike other events, `started_at` here is an RFC 3339 + * string. + */ +typedef struct lb_context_compress_started_payload_t { + /** + * Start time, RFC 3339 + */ + const char *started_at; + /** + * Compression input summary, as a JSON string; empty when absent + */ + const char *inputs_json; +} lb_context_compress_started_payload_t; + +/** + * Payload of a `ContextCompressFinished` conversation stream event. Unlike + * other events, `created_at` here is an RFC 3339 string. + */ +typedef struct lb_context_compress_finished_payload_t { + /** + * Finish time, RFC 3339 + */ + const char *created_at; + /** + * Compression input summary, as a JSON string; empty when absent + */ + const char *inputs_json; + /** + * Compression result summary, as a JSON string; empty when absent + */ + const char *outputs_json; +} lb_context_compress_finished_payload_t; + +/** + * Payload of a `ChatFinished` conversation stream event, observed once all + * `Message` events for this round have been sent, shortly before + * `WorkflowFinished` + */ +typedef struct lb_chat_finished_payload_t { + /** + * ID of the owning conversation + */ + int64_t chat_id; + /** + * Conversation identifier + */ + const char *chat_uid; + /** + * Message ID of this round + */ + const char *message_id; + /** + * Empty string in every run observed so far + */ + const char *error; + /** + * Empty string in every run observed so far + */ + const char *error_message; +} lb_chat_finished_payload_t; + +/** + * Payload of a `ChatTitleUpdated` conversation stream event — the server + * auto-generates a short title for the conversation as a UI convenience. + * Can arrive before *or* after `WorkflowFinished`; not tied to the run's + * outcome. + */ +typedef struct lb_chat_title_updated_payload_t { + /** + * ID of the owning conversation */ - InstitutionRecommendHold, + int64_t chat_id; /** - * Sell + * Conversation identifier */ - InstitutionRecommendSell, + const char *chat_uid; /** - * Strong sell + * Where the title came from, e.g. `"ai_generated"` */ - InstitutionRecommendStrongSell, + const char *source; /** - * Underperform + * The new (possibly truncated) title */ - InstitutionRecommendUnderperform, + const char *title; /** - * No opinion + * Unix timestamp in seconds */ - InstitutionRecommendNoOpinion, -} lb_institution_recommend_t; + int64_t updated_at; +} lb_chat_title_updated_payload_t; /** - * DCA plan status + * One event observed while streaming `lb_agent_context_conversation_streamed` + * or `lb_agent_context_continue_conversation_streamed`. + * + * This is a tagged union: `kind` tells you which one field below is + * non-null; all others are always null. When `kind` is `Ping` (a + * heartbeat with no payload), every field below is null. */ -typedef enum lb_dca_status_t { +typedef struct lb_conversation_stream_event_t { /** - * Plan is active + * Discriminant, tells you which field below is populated */ - DcaStatusActive, + enum lb_conversation_stream_event_type_t kind; /** - * Plan has been paused + * Non-null when `kind` is `ChatStarted` */ - DcaStatusSuspended, + const struct lb_chat_started_payload_t *chat_started; /** - * Plan has finished + * Non-null when `kind` is `WorkflowStarted`, observed right after + * `ChatStarted` on every run seen so far */ - DcaStatusFinished, -} lb_dca_status_t; - -/** - * Financial report period - */ -typedef enum lb_financial_report_period_t { + const struct lb_workflow_started_payload_t *workflow_started; /** - * Annual report + * Non-null when `kind` is `Message` */ - FinancialReportPeriodAnnual, + const struct lb_message_payload_t *message; /** - * Semi-annual report + * Non-null when `kind` is `ThinkingStarted`, the Agent entering the + * reasoning phase */ - FinancialReportPeriodSemiAnnual, + const struct lb_thinking_started_payload_t *thinking_started; /** - * Q1 report + * Non-null when `kind` is `ThinkingFinished`, the reasoning phase + * ending */ - FinancialReportPeriodQ1, + const struct lb_thinking_finished_payload_t *thinking_finished; /** - * Q2 report + * Non-null when `kind` is `NodeToolUseStarted`, an ordinary tool call + * starting */ - FinancialReportPeriodQ2, + const struct lb_node_tool_use_started_payload_t *node_tool_use_started; /** - * Q3 report + * Non-null when `kind` is `NodeToolUseFinished`, an ordinary tool call + * ending */ - FinancialReportPeriodQ3, + const struct lb_node_tool_use_finished_payload_t *node_tool_use_finished; /** - * Full quarterly report + * Non-null when `kind` is `SubagentStarted`, the Agent spawning a + * subagent to work on a sub-task */ - FinancialReportPeriodQuarterlyFull, + const struct lb_subagent_started_payload_t *subagent_started; /** - * Three-quarter report (first three quarters) + * Non-null when `kind` is `SubagentProgress`, the subagent calling one + * of its own tools */ - FinancialReportPeriodThreeQ, -} lb_financial_report_period_t; - -/** - * Flow direction - */ -typedef enum lb_flow_direction_t { + const struct lb_subagent_progress_payload_t *subagent_progress; /** - * Unknown direction + * Non-null when `kind` is `SubagentFinished`, the subagent finishing + * its sub-task */ - FlowDirectionUnknown, + const struct lb_subagent_finished_payload_t *subagent_finished; /** - * Buy + * Non-null when `kind` is `AgentToolStarted`, the Agent delegating to + * another Agent as a tool */ - FlowDirectionBuy, + const struct lb_agent_tool_started_payload_t *agent_tool_started; /** - * Sell + * Non-null when `kind` is `AgentToolProgress`, the delegated Agent + * calling one of its own tools */ - FlowDirectionSell, -} lb_flow_direction_t; - -/** - * Asset type - */ -typedef enum lb_asset_type_t { + const struct lb_agent_tool_progress_payload_t *agent_tool_progress; /** - * Unknown type + * Non-null when `kind` is `AgentToolFinished`, the delegated Agent's + * run finishing */ - AssetTypeUnknown, + const struct lb_agent_tool_finished_payload_t *agent_tool_finished; /** - * Stock + * Non-null when `kind` is `HumanInteractionRequired`, carrying the + * run's outcome for an interrupted run. Unlike `WorkflowFinished`, this + * is emitted instead of (never alongside) `WorkflowFinished` for the + * same run */ - AssetTypeStock, + const struct lb_conversation_response_t *human_interaction_required; /** - * Fund + * Non-null when `kind` is `QueryMasked`, sensitive content in the user + * query having been masked before processing */ - AssetTypeFund, + const struct lb_query_masked_payload_t *query_masked; /** - * Crypto + * Non-null when `kind` is `PlanChanged`, the Agent creating or + * updating its task plan */ - AssetTypeCrypto, -} lb_asset_type_t; - -/** - * ETF asset allocation element type - */ -typedef enum lb_element_type_t { + const struct lb_plan_changed_payload_t *plan_changed; /** - * Unknown + * Non-null when `kind` is `ContextCompressStarted`, a + * context-compression pass starting */ - ElementTypeUnknown, + const struct lb_context_compress_started_payload_t *context_compress_started; /** - * Holdings + * Non-null when `kind` is `ContextCompressFinished`, a + * context-compression pass finishing */ - ElementTypeHoldings, + const struct lb_context_compress_finished_payload_t *context_compress_finished; /** - * Regional + * Non-null when `kind` is `ChatFinished`, observed once all `Message` + * events for this round have been sent */ - ElementTypeRegional, + const struct lb_chat_finished_payload_t *chat_finished; /** - * Asset class + * Non-null when `kind` is `WorkflowFinished`, carrying the run's + * outcome — not necessarily the last event of the stream, since the + * server may still emit a few more housekeeping events before actually + * closing the connection */ - ElementTypeAssetClass, + const struct lb_conversation_response_t *workflow_finished; /** - * Industry + * Non-null when `kind` is `ChatTitleUpdated`, the server auto-generating + * a short title for the conversation */ - ElementTypeIndustry, -} lb_element_type_t; - -typedef struct lb_alert_context_t lb_alert_context_t; - -/** - * Asset context - */ -typedef struct CAssetContext CAssetContext; - -typedef struct lb_calendar_context_t lb_calendar_context_t; - -/** - * Configuration options for Longbridge SDK - */ -typedef struct lb_config_t lb_config_t; - -/** - * Content context - */ -typedef struct lb_content_context_t lb_content_context_t; - -typedef struct lb_dca_context_t lb_dca_context_t; - -typedef struct lb_decimal_t lb_decimal_t; - -typedef struct lb_error_t lb_error_t; - -typedef struct lb_fundamental_context_t lb_fundamental_context_t; - -/** - * A HTTP client for Longbridge OpenAPI - */ -typedef struct lb_http_client_t lb_http_client_t; - -typedef struct lb_http_result_t lb_http_result_t; - -/** - * Market data context - */ -typedef struct lb_market_context_t lb_market_context_t; + const struct lb_chat_title_updated_payload_t *chat_title_updated; + /** + * Non-null when `kind` is `Other`; the SSE envelope's `event` field (the + * event type name) + */ + const char *other_event; + /** + * Non-null when `kind` is `Other`; raw JSON of an event type not + * recognized by this SDK version + */ + const char *other_json; +} lb_conversation_stream_event_t; /** - * OAuth 2.0 client — owns the Rust `OAuth` instance (opaque handle) + * Called once for every event observed while streaming a conversation. See + * `lb_agent_context_conversation_streamed`/ + * `lb_agent_context_continue_conversation_streamed`. * - * Callers must never dereference or inspect the struct layout. - * Always free with `lb_oauth_free`. - */ -typedef struct lb_oauth_t lb_oauth_t; - -typedef struct lb_portfolio_context_t lb_portfolio_context_t; - -/** - * Quote context - */ -typedef struct lb_quote_context_t lb_quote_context_t; - -typedef struct lb_screener_context_t lb_screener_context_t; - -typedef struct lb_sharelist_context_t lb_sharelist_context_t; - -/** - * Trade context + * Unlike the `lb_xxx_context_set_on_xxx` push callbacks, this callback is + * scoped to a single streamed call — it's supplied directly as an argument + * and is never stored on the context. */ -typedef struct lb_trade_context_t lb_trade_context_t; - -typedef struct lb_async_result_t { - const void *ctx; - const struct lb_error_t *error; - void *data; - uintptr_t length; - void *userdata; -} lb_async_result_t; +typedef void (*lb_conversation_event_callback_t)(const struct lb_agent_context_t*, + const struct lb_conversation_stream_event_t*, + void*); -typedef void (*lb_async_callback_t)(const struct lb_async_result_t*); +typedef void (*lb_free_userdata_func_t)(void*); /** * A single alert indicator configuration for a symbol. @@ -1723,8 +2932,6 @@ typedef struct lb_http_header_t { const char *value; } lb_http_header_t; -typedef void (*lb_free_userdata_func_t)(void*); - /** * Quote message */ @@ -8839,10 +10046,227 @@ typedef struct lb_screener_indicators_response_t { const char *data; } lb_screener_indicators_response_t; +/** + * A Workspace the current account belongs to + */ +typedef struct lb_workspace_t { + /** + * Workspace ID + */ + const char *id; + /** + * Workspace name + */ + const char *name; + /** + * Creation time, Unix timestamp in seconds + */ + int64_t created_at; + /** + * Last updated time, Unix timestamp in seconds + */ + int64_t updated_at; +} lb_workspace_t; + +/** + * Response for `lb_agent_context_workspaces` + */ +typedef struct lb_workspaces_response_t { + /** + * Workspaces the current account belongs to + */ + const struct lb_workspace_t *workspaces; + /** + * Number of workspaces + */ + uintptr_t num_workspaces; +} lb_workspaces_response_t; + +/** + * An Agent in a Workspace + */ +typedef struct lb_agent_t { + /** + * Agent UID, used as the path parameter of + * `lb_agent_context_conversation` + */ + const char *uid; + /** + * Agent name + */ + const char *name; + /** + * Agent description + */ + const char *description; + /** + * Agent mode, e.g. `chat` + */ + const char *mode; + /** + * Icon URL + */ + const char *icon; + /** + * Whether published; only published Agents can start conversations + */ + bool is_published; + /** + * Publish time, Unix timestamp in seconds; 0 if unpublished + */ + int64_t published_at; + /** + * Creation time, Unix timestamp in seconds + */ + int64_t created_at; + /** + * Last updated time, Unix timestamp in seconds + */ + int64_t updated_at; +} lb_agent_t; + +/** + * Response for `lb_agent_context_agents` + */ +typedef struct lb_agents_response_t { + /** + * Agent list + */ + const struct lb_agent_t *agents; + /** + * Number of agents in the array + */ + uintptr_t num_agents; + /** + * Total number of matching Agents + */ + int32_t total; +} lb_agents_response_t; + #ifdef __cplusplus extern "C" { #endif // __cplusplus +const struct lb_agent_context_t *lb_agent_context_new(const struct lb_config_t *config); + +void lb_agent_context_retain(const struct lb_agent_context_t *ctx); + +void lb_agent_context_release(const struct lb_agent_context_t *ctx); + +uintptr_t lb_agent_context_ref_count(const struct lb_agent_context_t *ctx); + +/** + * List the Workspaces the current account belongs to. Returns + * `CWorkspacesResponse`. + */ +void lb_agent_context_workspaces(const struct lb_agent_context_t *ctx, + lb_async_callback_t callback, + void *userdata); + +/** + * List the Agents in the specified Workspace. Returns `CAgentsResponse`. + * + * @param[in] opts Options for get agents request (can be null) + */ +void lb_agent_context_agents(const struct lb_agent_context_t *ctx, + const char *workspace_id, + const struct lb_get_agents_options_t *opts, + lb_async_callback_t callback, + void *userdata); + +/** + * Start a conversation with the specified Agent, blocking until the run + * succeeds, is interrupted, or fails. Returns `CConversationResponse`. + * + * @param[in] chat_uid Existing conversation identifier to continue within + * (can be null to start a brand-new conversation) + */ +void lb_agent_context_conversation(const struct lb_agent_context_t *ctx, + const char *agent_id, + const char *query, + const char *chat_uid, + lb_async_callback_t callback, + void *userdata); + +/** + * Resume an interrupted conversation, blocking until the run succeeds, is + * interrupted again, or fails. Returns `CConversationResponse`. + * + * @param[in] answers Answers keyed by `tool_call_id`, see + * `CAnswersByToolCallEntry` (can be null if + * `num_answers` is 0) + * @param[in] num_answers Number of entries in `answers` + */ +void lb_agent_context_continue_conversation(const struct lb_agent_context_t *ctx, + const char *agent_id, + const char *chat_uid, + const char *message_id, + const struct lb_answers_by_tool_call_entry_t *answers, + uintptr_t num_answers, + lb_async_callback_t callback, + void *userdata); + +/** + * Start a conversation with the specified Agent, calling `event_callback` + * for every run-progress event observed over SSE. A `WorkflowFinished` + * event carries the run's outcome, but isn't necessarily the last one seen — + * the server may still emit a few more housekeeping events (e.g. a + * `ChatTitleUpdated`) before actually closing the connection. Once the + * stream truly ends, `callback` is invoked with the final + * `CConversationResponse` — same as `lb_agent_context_conversation`, just + * arrived at via the streamed path. + * + * @param[in] chat_uid Existing conversation identifier to + * continue within (can be null to start a + * brand-new conversation) + * @param[in] event_callback Called once per stream event, on an + * internal worker thread + * @param[in] event_userdata Opaque pointer forwarded to + * `event_callback` + * @param[in] event_free_userdata Called exactly once, after the stream ends + * (successfully or not), to free + * `event_userdata` (can be null) + */ +void lb_agent_context_conversation_streamed(const struct lb_agent_context_t *ctx, + const char *agent_id, + const char *query, + const char *chat_uid, + lb_conversation_event_callback_t event_callback, + void *event_userdata, + lb_free_userdata_func_t event_free_userdata, + lb_async_callback_t callback, + void *userdata); + +/** + * Resume an interrupted conversation, calling `event_callback` for every + * run-progress event observed over SSE, then `callback` with the final + * `CConversationResponse` once the stream ends — same shape as + * `lb_agent_context_conversation_streamed`. + * + * @param[in] answers Answers keyed by `tool_call_id`, see + * `CAnswersByToolCallEntry` (can be null if + * `num_answers` is 0) + * @param[in] num_answers Number of entries in `answers` + * @param[in] event_callback Called once per stream event, on an + * internal worker thread + * @param[in] event_userdata Opaque pointer forwarded to + * `event_callback` + * @param[in] event_free_userdata Called exactly once, after the stream ends + * (successfully or not), to free + * `event_userdata` (can be null) + */ +void lb_agent_context_continue_conversation_streamed(const struct lb_agent_context_t *ctx, + const char *agent_id, + const char *chat_uid, + const char *message_id, + const struct lb_answers_by_tool_call_entry_t *answers, + uintptr_t num_answers, + lb_conversation_event_callback_t event_callback, + void *event_userdata, + lb_free_userdata_func_t event_free_userdata, + lb_async_callback_t callback, + void *userdata); + const struct lb_alert_context_t *lb_alert_context_new(const struct lb_config_t *config); void lb_alert_context_retain(const struct lb_alert_context_t *ctx); diff --git a/c/src/agent_context/context.rs b/c/src/agent_context/context.rs new file mode 100644 index 0000000000..0b0daede6f --- /dev/null +++ b/c/src/agent_context/context.rs @@ -0,0 +1,306 @@ +use std::{collections::HashMap, ffi::c_void, os::raw::c_char, sync::Arc}; + +use longbridge::agent::{ + AgentContext, AnswersByToolCall, GetAgentsOptions, drive_conversation_stream, +}; + +use crate::{ + agent_context::types::{ + CAgentsResponseOwned, CAnswersByToolCallEntry, CConversationResponseOwned, + CConversationStreamEvent, CConversationStreamEventOwned, CGetAgentsOptions, + CWorkspacesResponseOwned, + }, + async_call::{CAsyncCallback, execute_async}, + callback::CFreeUserDataFunc, + config::CConfig, + types::{CCow, ToFFI, cstr_to_rust}, +}; + +/// AI Agent conversation context +pub struct CAgentContext { + ctx: AgentContext, +} + +/// Called once for every event observed while streaming a conversation. See +/// `lb_agent_context_conversation_streamed`/ +/// `lb_agent_context_continue_conversation_streamed`. +/// +/// Unlike the `lb_xxx_context_set_on_xxx` push callbacks, this callback is +/// scoped to a single streamed call — it's supplied directly as an argument +/// and is never stored on the context. +pub type COnConversationEventCallback = + extern "C" fn(*const CAgentContext, *const CConversationStreamEvent, *mut c_void); + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_new(config: *const CConfig) -> *const CAgentContext { + Arc::into_raw(Arc::new(CAgentContext { + ctx: AgentContext::new(Arc::new((*config).0.clone())), + })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_retain(ctx: *const CAgentContext) { + Arc::increment_strong_count(ctx); +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_release(ctx: *const CAgentContext) { + let _ = Arc::from_raw(ctx); +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_ref_count(ctx: *const CAgentContext) -> usize { + Arc::increment_strong_count(ctx); + let ctx = Arc::from_raw(ctx); + Arc::strong_count(&ctx) +} + +/// Flatten the C representation of `answers_by_tool_call` (an array of +/// `(tool_call_id, [(question, answer)])` entries) back into the nested-map +/// shape (`HashMap>`) the Rust core expects. +/// See the doc comment on `CAnswersByToolCallEntry` for why the C side is +/// shaped this way. +unsafe fn answers_from_ffi( + answers: *const CAnswersByToolCallEntry, + num_answers: usize, +) -> AnswersByToolCall { + let mut map: AnswersByToolCall = HashMap::new(); + for entry in std::slice::from_raw_parts(answers, num_answers) { + let tool_call_id = cstr_to_rust(entry.tool_call_id); + let mut questions = HashMap::new(); + for qa in std::slice::from_raw_parts(entry.answers, entry.num_answers) { + questions.insert(cstr_to_rust(qa.question), cstr_to_rust(qa.answer)); + } + map.insert(tool_call_id, questions); + } + map +} + +/// List the Workspaces the current account belongs to. Returns +/// `CWorkspacesResponse`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_workspaces( + ctx: *const CAgentContext, + callback: CAsyncCallback, + userdata: *mut c_void, +) { + let ctx_inner = (*ctx).ctx.clone(); + execute_async(callback, ctx, userdata, async move { + let resp: CCow = CCow::new(ctx_inner.workspaces().await?); + Ok(resp) + }); +} + +/// List the Agents in the specified Workspace. Returns `CAgentsResponse`. +/// +/// @param[in] opts Options for get agents request (can be null) +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_agents( + ctx: *const CAgentContext, + workspace_id: *const c_char, + opts: *const CGetAgentsOptions, + callback: CAsyncCallback, + userdata: *mut c_void, +) { + let ctx_inner = (*ctx).ctx.clone(); + let workspace_id = cstr_to_rust(workspace_id); + let mut opts2 = GetAgentsOptions::new(); + if !opts.is_null() { + if !(*opts).page.is_null() { + opts2 = opts2.page(*(*opts).page); + } + if !(*opts).limit.is_null() { + opts2 = opts2.limit(*(*opts).limit); + } + if !(*opts).name.is_null() { + opts2 = opts2.name(cstr_to_rust((*opts).name)); + } + } + execute_async(callback, ctx, userdata, async move { + let resp: CCow = + CCow::new(ctx_inner.agents(workspace_id, opts2).await?); + Ok(resp) + }); +} + +/// Start a conversation with the specified Agent, blocking until the run +/// succeeds, is interrupted, or fails. Returns `CConversationResponse`. +/// +/// @param[in] chat_uid Existing conversation identifier to continue within +/// (can be null to start a brand-new conversation) +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_conversation( + ctx: *const CAgentContext, + agent_id: *const c_char, + query: *const c_char, + chat_uid: *const c_char, + callback: CAsyncCallback, + userdata: *mut c_void, +) { + let ctx_inner = (*ctx).ctx.clone(); + let agent_id = cstr_to_rust(agent_id); + let query = cstr_to_rust(query); + let chat_uid = (!chat_uid.is_null()).then(|| cstr_to_rust(chat_uid)); + execute_async(callback, ctx, userdata, async move { + let resp: CCow = + CCow::new(ctx_inner.conversation(agent_id, query, chat_uid).await?); + Ok(resp) + }); +} + +/// Resume an interrupted conversation, blocking until the run succeeds, is +/// interrupted again, or fails. Returns `CConversationResponse`. +/// +/// @param[in] answers Answers keyed by `tool_call_id`, see +/// `CAnswersByToolCallEntry` (can be null if +/// `num_answers` is 0) +/// @param[in] num_answers Number of entries in `answers` +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_continue_conversation( + ctx: *const CAgentContext, + agent_id: *const c_char, + chat_uid: *const c_char, + message_id: *const c_char, + answers: *const CAnswersByToolCallEntry, + num_answers: usize, + callback: CAsyncCallback, + userdata: *mut c_void, +) { + let ctx_inner = (*ctx).ctx.clone(); + let agent_id = cstr_to_rust(agent_id); + let chat_uid = cstr_to_rust(chat_uid); + let message_id = cstr_to_rust(message_id); + let answers = answers_from_ffi(answers, num_answers); + execute_async(callback, ctx, userdata, async move { + let resp: CCow = CCow::new( + ctx_inner + .continue_conversation(agent_id, chat_uid, message_id, answers) + .await?, + ); + Ok(resp) + }); +} + +/// Start a conversation with the specified Agent, calling `event_callback` +/// for every run-progress event observed over SSE. A `WorkflowFinished` +/// event carries the run's outcome, but isn't necessarily the last one seen — +/// the server may still emit a few more housekeeping events (e.g. a +/// `ChatTitleUpdated`) before actually closing the connection. Once the +/// stream truly ends, `callback` is invoked with the final +/// `CConversationResponse` — same as `lb_agent_context_conversation`, just +/// arrived at via the streamed path. +/// +/// @param[in] chat_uid Existing conversation identifier to +/// continue within (can be null to start a +/// brand-new conversation) +/// @param[in] event_callback Called once per stream event, on an +/// internal worker thread +/// @param[in] event_userdata Opaque pointer forwarded to +/// `event_callback` +/// @param[in] event_free_userdata Called exactly once, after the stream ends +/// (successfully or not), to free +/// `event_userdata` (can be null) +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_conversation_streamed( + ctx: *const CAgentContext, + agent_id: *const c_char, + query: *const c_char, + chat_uid: *const c_char, + event_callback: COnConversationEventCallback, + event_userdata: *mut c_void, + event_free_userdata: CFreeUserDataFunc, + callback: CAsyncCallback, + userdata: *mut c_void, +) { + let ctx_inner = (*ctx).ctx.clone(); + let agent_id = cstr_to_rust(agent_id); + let query = cstr_to_rust(query); + let chat_uid = (!chat_uid.is_null()).then(|| cstr_to_rust(chat_uid)); + // Raw pointers aren't `Send`, so thread them through the future as plain + // addresses (mirroring how `execute_async` itself carries `ctx`/ + // `userdata` across the `spawn` boundary) and only reconstitute them + // inside the synchronous `on_event` closure below. + let ctx_addr = ctx as usize; + let event_userdata_addr = event_userdata as usize; + execute_async(callback, ctx, userdata, async move { + let stream = Box::pin( + ctx_inner + .conversation_streamed(agent_id, query, chat_uid) + .await?, + ); + let result = drive_conversation_stream(stream, move |event| { + let event_owned: CConversationStreamEventOwned = event.into(); + event_callback( + ctx_addr as *const CAgentContext, + &event_owned.to_ffi_type(), + event_userdata_addr as *mut c_void, + ); + }) + .await; + if let Some(free_userdata) = event_free_userdata { + free_userdata(event_userdata_addr as *mut c_void); + } + let resp: CCow = CCow::new(result?); + Ok(resp) + }); +} + +/// Resume an interrupted conversation, calling `event_callback` for every +/// run-progress event observed over SSE, then `callback` with the final +/// `CConversationResponse` once the stream ends — same shape as +/// `lb_agent_context_conversation_streamed`. +/// +/// @param[in] answers Answers keyed by `tool_call_id`, see +/// `CAnswersByToolCallEntry` (can be null if +/// `num_answers` is 0) +/// @param[in] num_answers Number of entries in `answers` +/// @param[in] event_callback Called once per stream event, on an +/// internal worker thread +/// @param[in] event_userdata Opaque pointer forwarded to +/// `event_callback` +/// @param[in] event_free_userdata Called exactly once, after the stream ends +/// (successfully or not), to free +/// `event_userdata` (can be null) +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_agent_context_continue_conversation_streamed( + ctx: *const CAgentContext, + agent_id: *const c_char, + chat_uid: *const c_char, + message_id: *const c_char, + answers: *const CAnswersByToolCallEntry, + num_answers: usize, + event_callback: COnConversationEventCallback, + event_userdata: *mut c_void, + event_free_userdata: CFreeUserDataFunc, + callback: CAsyncCallback, + userdata: *mut c_void, +) { + let ctx_inner = (*ctx).ctx.clone(); + let agent_id = cstr_to_rust(agent_id); + let chat_uid = cstr_to_rust(chat_uid); + let message_id = cstr_to_rust(message_id); + let answers = answers_from_ffi(answers, num_answers); + let ctx_addr = ctx as usize; + let event_userdata_addr = event_userdata as usize; + execute_async(callback, ctx, userdata, async move { + let stream = Box::pin( + ctx_inner + .continue_conversation_streamed(agent_id, chat_uid, message_id, answers) + .await?, + ); + let result = drive_conversation_stream(stream, move |event| { + let event_owned: CConversationStreamEventOwned = event.into(); + event_callback( + ctx_addr as *const CAgentContext, + &event_owned.to_ffi_type(), + event_userdata_addr as *mut c_void, + ); + }) + .await; + if let Some(free_userdata) = event_free_userdata { + free_userdata(event_userdata_addr as *mut c_void); + } + let resp: CCow = CCow::new(result?); + Ok(resp) + }); +} diff --git a/c/src/agent_context/enum_types.rs b/c/src/agent_context/enum_types.rs new file mode 100644 index 0000000000..7e1554928d --- /dev/null +++ b/c/src/agent_context/enum_types.rs @@ -0,0 +1,100 @@ +use longbridge_c_macros::CEnum; + +/// Final run status of a conversation +#[derive(Debug, Copy, Clone, Eq, PartialEq, CEnum)] +#[c(remote = "longbridge::agent::ConversationStatus")] +#[allow(clippy::enum_variant_names)] +#[repr(C)] +pub enum CConversationStatus { + /// The run completed successfully + #[c(remote = "Succeeded")] + ConversationStatusSucceeded, + /// The run is paused, waiting for + /// `lb_agent_context_continue_conversation`/ + /// `lb_agent_context_continue_conversation_streamed` + #[c(remote = "Interrupted")] + ConversationStatusInterrupted, + /// The run failed + #[c(remote = "Failed")] + ConversationStatusFailed, + /// The run was stopped + #[c(remote = "Stopped")] + ConversationStatusStopped, +} + +/// Kind of a [`crate::agent_context::types::CConversationStreamEvent`]. Only +/// the field matching this kind is non-null, all others are null. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +#[repr(C)] +pub enum CConversationStreamEventType { + /// The run has started; `chat_started` is non-null + ChatStarted, + /// Observed right after `ChatStarted` on every run seen so far; + /// `workflow_started` is non-null + WorkflowStarted, + /// An incremental piece of the answer; `message` is non-null + Message, + /// A heartbeat with no payload, observed at arbitrary points in the + /// stream (including in between `Message` chunks); every field below is + /// null + Ping, + /// The Agent has entered the reasoning phase; `thinking_started` is + /// non-null + ThinkingStarted, + /// The reasoning phase is over; `thinking_finished` is non-null + ThinkingFinished, + /// An ordinary tool call has started; `node_tool_use_started` is + /// non-null + NodeToolUseStarted, + /// An ordinary tool call has ended; `node_tool_use_finished` is + /// non-null + NodeToolUseFinished, + /// The Agent has spawned a subagent to work on a sub-task; + /// `subagent_started` is non-null + SubagentStarted, + /// The subagent has called one of its own tools; `subagent_progress` is + /// non-null + SubagentProgress, + /// The subagent has finished its sub-task; `subagent_finished` is + /// non-null + SubagentFinished, + /// The Agent has delegated to another Agent as a tool; + /// `agent_tool_started` is non-null + AgentToolStarted, + /// The delegated Agent has called one of its own tools; + /// `agent_tool_progress` is non-null + AgentToolProgress, + /// The delegated Agent's run has finished; `agent_tool_finished` is + /// non-null + AgentToolFinished, + /// The run is paused: the Agent needs more information or confirmation + /// from you; `human_interaction_required` is non-null. Unlike + /// `WorkflowFinished`, this is emitted instead of (never alongside) + /// `WorkflowFinished` for the same run + HumanInteractionRequired, + /// Sensitive content in the user query was masked before processing; + /// `query_masked` is non-null + QueryMasked, + /// The Agent created or updated its task plan; `plan_changed` is + /// non-null + PlanChanged, + /// A context-compression pass has started; `context_compress_started` + /// is non-null + ContextCompressStarted, + /// The context-compression pass has finished; + /// `context_compress_finished` is non-null + ContextCompressFinished, + /// Observed once all `Message` events for this round have been sent; + /// `chat_finished` is non-null + ChatFinished, + /// The run finished successfully, with a failure, or stopped by the + /// user; `workflow_finished` is non-null. Never emitted for an + /// interrupted run — see `HumanInteractionRequired` for that case + WorkflowFinished, + /// The server auto-generating a short title for the conversation; + /// `chat_title_updated` is non-null + ChatTitleUpdated, + /// An event type not recognized by this SDK version; `other_json` is + /// non-null and contains the raw event JSON + Other, +} diff --git a/c/src/agent_context/mod.rs b/c/src/agent_context/mod.rs new file mode 100644 index 0000000000..127deedfcc --- /dev/null +++ b/c/src/agent_context/mod.rs @@ -0,0 +1,3 @@ +mod context; +mod enum_types; +mod types; diff --git a/c/src/agent_context/types.rs b/c/src/agent_context/types.rs new file mode 100644 index 0000000000..f98b22e80b --- /dev/null +++ b/c/src/agent_context/types.rs @@ -0,0 +1,2520 @@ +use std::os::raw::c_char; + +use longbridge::agent::{ + Agent, AgentError, AgentToolFinishedPayload, AgentToolProgressPayload, AgentToolStartedPayload, + AgentsResponse, ChatFinishedPayload, ChatStartedPayload, ChatTitleUpdatedPayload, + ContextCompressFinishedPayload, ContextCompressStartedPayload, ConversationResponse, + ConversationStreamEvent, Interrupt, MessagePayload, NodeToolUseFinishedPayload, + NodeToolUseOutputs, NodeToolUseStartedPayload, PlanChangedPayload, QueryMaskedPayload, + Question, QuestionOption, Reference, SubagentFinishedPayload, SubagentOutputs, + SubagentProgressPayload, SubagentStartedPayload, ThinkingFinishedPayload, + ThinkingStartedPayload, WorkflowStartedInputs, WorkflowStartedPayload, Workspace, + WorkspacesResponse, +}; + +use crate::{ + agent_context::enum_types::{CConversationStatus, CConversationStreamEventType}, + types::{CCow, CString, CVec, ToFFI}, +}; + +/// A Workspace the current account belongs to +#[repr(C)] +pub struct CWorkspace { + /// Workspace ID + pub id: *const c_char, + /// Workspace name + pub name: *const c_char, + /// Creation time, Unix timestamp in seconds + pub created_at: i64, + /// Last updated time, Unix timestamp in seconds + pub updated_at: i64, +} + +#[derive(Debug)] +pub(crate) struct CWorkspaceOwned { + id: CString, + name: CString, + created_at: i64, + updated_at: i64, +} + +impl From for CWorkspaceOwned { + fn from(v: Workspace) -> Self { + let Workspace { + id, + name, + created_at, + updated_at, + } = v; + Self { + id: id.into(), + name: name.into(), + created_at, + updated_at, + } + } +} + +impl ToFFI for CWorkspaceOwned { + type FFIType = CWorkspace; + + fn to_ffi_type(&self) -> Self::FFIType { + let CWorkspaceOwned { + id, + name, + created_at, + updated_at, + } = self; + CWorkspace { + id: id.to_ffi_type(), + name: name.to_ffi_type(), + created_at: *created_at, + updated_at: *updated_at, + } + } +} + +/// Response for `lb_agent_context_workspaces` +#[repr(C)] +pub struct CWorkspacesResponse { + /// Workspaces the current account belongs to + pub workspaces: *const CWorkspace, + /// Number of workspaces + pub num_workspaces: usize, +} + +pub(crate) struct CWorkspacesResponseOwned { + workspaces: CVec, +} + +impl From for CWorkspacesResponseOwned { + fn from(v: WorkspacesResponse) -> Self { + Self { + workspaces: v.workspaces.into(), + } + } +} + +impl ToFFI for CWorkspacesResponseOwned { + type FFIType = CWorkspacesResponse; + + fn to_ffi_type(&self) -> Self::FFIType { + CWorkspacesResponse { + workspaces: self.workspaces.to_ffi_type(), + num_workspaces: self.workspaces.len(), + } + } +} + +/// An Agent in a Workspace +#[repr(C)] +pub struct CAgent { + /// Agent UID, used as the path parameter of + /// `lb_agent_context_conversation` + pub uid: *const c_char, + /// Agent name + pub name: *const c_char, + /// Agent description + pub description: *const c_char, + /// Agent mode, e.g. `chat` + pub mode: *const c_char, + /// Icon URL + pub icon: *const c_char, + /// Whether published; only published Agents can start conversations + pub is_published: bool, + /// Publish time, Unix timestamp in seconds; 0 if unpublished + pub published_at: i64, + /// Creation time, Unix timestamp in seconds + pub created_at: i64, + /// Last updated time, Unix timestamp in seconds + pub updated_at: i64, +} + +#[derive(Debug)] +pub(crate) struct CAgentOwned { + uid: CString, + name: CString, + description: CString, + mode: CString, + icon: CString, + is_published: bool, + published_at: i64, + created_at: i64, + updated_at: i64, +} + +impl From for CAgentOwned { + fn from(v: Agent) -> Self { + let Agent { + uid, + name, + description, + mode, + icon, + is_published, + published_at, + created_at, + updated_at, + } = v; + Self { + uid: uid.into(), + name: name.into(), + description: description.into(), + mode: mode.into(), + icon: icon.into(), + is_published, + published_at, + created_at, + updated_at, + } + } +} + +impl ToFFI for CAgentOwned { + type FFIType = CAgent; + + fn to_ffi_type(&self) -> Self::FFIType { + let CAgentOwned { + uid, + name, + description, + mode, + icon, + is_published, + published_at, + created_at, + updated_at, + } = self; + CAgent { + uid: uid.to_ffi_type(), + name: name.to_ffi_type(), + description: description.to_ffi_type(), + mode: mode.to_ffi_type(), + icon: icon.to_ffi_type(), + is_published: *is_published, + published_at: *published_at, + created_at: *created_at, + updated_at: *updated_at, + } + } +} + +/// Response for `lb_agent_context_agents` +#[repr(C)] +pub struct CAgentsResponse { + /// Agent list + pub agents: *const CAgent, + /// Number of agents in the array + pub num_agents: usize, + /// Total number of matching Agents + pub total: i32, +} + +pub(crate) struct CAgentsResponseOwned { + agents: CVec, + total: i32, +} + +impl From for CAgentsResponseOwned { + fn from(v: AgentsResponse) -> Self { + let AgentsResponse { agents, total } = v; + Self { + agents: agents.into(), + total, + } + } +} + +impl ToFFI for CAgentsResponseOwned { + type FFIType = CAgentsResponse; + + fn to_ffi_type(&self) -> Self::FFIType { + CAgentsResponse { + agents: self.agents.to_ffi_type(), + num_agents: self.agents.len(), + total: self.total, + } + } +} + +/// Options for `lb_agent_context_agents` (all fields can be null) +#[repr(C)] +pub struct CGetAgentsOptions { + /// Page number, starts at 1 (can be null) + pub page: *const i32, + /// Page size (can be null) + pub limit: *const i32, + /// Fuzzy search by Agent name (can be null) + pub name: *const c_char, +} + +/// A source referenced by the answer +#[repr(C)] +pub struct CReference { + /// Reference index + pub index: i32, + /// Reference title + pub title: *const c_char, + /// Reference URL + pub url: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CReferenceOwned { + index: i32, + title: CString, + url: CString, +} + +impl From for CReferenceOwned { + fn from(v: Reference) -> Self { + let Reference { index, title, url } = v; + Self { + index, + title: title.into(), + url: url.into(), + } + } +} + +impl ToFFI for CReferenceOwned { + type FFIType = CReference; + + fn to_ffi_type(&self) -> Self::FFIType { + let CReferenceOwned { index, title, url } = self; + CReference { + index: *index, + title: title.to_ffi_type(), + url: url.to_ffi_type(), + } + } +} + +/// One option of a [`CQuestion`] +#[repr(C)] +pub struct CQuestionOption { + /// Option text + pub description: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CQuestionOptionOwned { + description: CString, +} + +impl From for CQuestionOptionOwned { + fn from(v: QuestionOption) -> Self { + Self { + description: v.description.into(), + } + } +} + +impl ToFFI for CQuestionOptionOwned { + type FFIType = CQuestionOption; + + fn to_ffi_type(&self) -> Self::FFIType { + CQuestionOption { + description: self.description.to_ffi_type(), + } + } +} + +/// One question the Agent needs you to answer +#[repr(C)] +pub struct CQuestion { + /// Question text + pub question: *const c_char, + /// Options; empty means free-form answer + pub options: *const CQuestionOption, + /// Number of options + pub num_options: usize, + /// Whether multiple options may be selected + pub multi_select: bool, +} + +#[derive(Debug)] +pub(crate) struct CQuestionOwned { + question: CString, + options: CVec, + multi_select: bool, +} + +impl From for CQuestionOwned { + fn from(v: Question) -> Self { + let Question { + question, + options, + multi_select, + } = v; + Self { + question: question.into(), + options: options.into(), + multi_select, + } + } +} + +impl ToFFI for CQuestionOwned { + type FFIType = CQuestion; + + fn to_ffi_type(&self) -> Self::FFIType { + let CQuestionOwned { + question, + options, + multi_select, + } = self; + CQuestion { + question: question.to_ffi_type(), + options: options.to_ffi_type(), + num_options: options.len(), + multi_select: *multi_select, + } + } +} + +/// Present when a conversation run is interrupted, waiting for +/// `lb_agent_context_continue_conversation` +#[repr(C)] +pub struct CInterrupt { + /// ID of the node that triggered the interrupt + pub node_id: *const c_char, + /// Tool call ID of this inquiry; used as the answer key when continuing + pub tool_call_id: *const c_char, + /// Questions you need to answer + pub questions: *const CQuestion, + /// Number of questions + pub num_questions: usize, + /// ID of the paused message + pub message_id: i64, + /// ID of the owning conversation + pub chat_id: i64, +} + +#[derive(Debug)] +pub(crate) struct CInterruptOwned { + node_id: CString, + tool_call_id: CString, + questions: CVec, + message_id: i64, + chat_id: i64, +} + +impl From for CInterruptOwned { + fn from(v: Interrupt) -> Self { + let Interrupt { + node_id, + tool_call_id, + questions, + message_id, + chat_id, + } = v; + Self { + node_id: node_id.into(), + tool_call_id: tool_call_id.into(), + questions: questions.into(), + message_id, + chat_id, + } + } +} + +impl ToFFI for CInterruptOwned { + type FFIType = CInterrupt; + + fn to_ffi_type(&self) -> Self::FFIType { + let CInterruptOwned { + node_id, + tool_call_id, + questions, + message_id, + chat_id, + } = self; + CInterrupt { + node_id: node_id.to_ffi_type(), + tool_call_id: tool_call_id.to_ffi_type(), + questions: questions.to_ffi_type(), + num_questions: questions.len(), + message_id: *message_id, + chat_id: *chat_id, + } + } +} + +/// Present when a conversation run failed +#[repr(C)] +pub struct CAgentError { + /// Error code + pub code: i32, + /// Error message + pub message: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CAgentErrorOwned { + code: i32, + message: CString, +} + +impl From for CAgentErrorOwned { + fn from(v: AgentError) -> Self { + let AgentError { code, message } = v; + Self { + code, + message: message.into(), + } + } +} + +impl ToFFI for CAgentErrorOwned { + type FFIType = CAgentError; + + fn to_ffi_type(&self) -> Self::FFIType { + CAgentError { + code: self.code, + message: self.message.to_ffi_type(), + } + } +} + +/// Response for `lb_agent_context_conversation`, +/// `lb_agent_context_continue_conversation`, and the final result of the +/// streamed counterparts +#[repr(C)] +pub struct CConversationResponse { + /// Conversation identifier, used for follow-up questions and + /// troubleshooting + pub chat_uid: *const c_char, + /// Message ID of this round + pub message_id: *const c_char, + /// Final run status + pub status: CConversationStatus, + /// Final answer text; valid when `status` is + /// `ConversationStatusSucceeded` + pub answer: *const c_char, + /// Sources referenced by the answer + pub references: *const CReference, + /// Number of references + pub num_references: usize, + /// Run duration in seconds + pub elapsed_time: f64, + /// Present only when `status` is `ConversationStatusInterrupted` (can be + /// null) + pub interrupt: *const CInterrupt, + /// Present only when the run failed (can be null) + pub error: *const CAgentError, +} + +pub(crate) struct CConversationResponseOwned { + chat_uid: CString, + message_id: CString, + status: CConversationStatus, + answer: CString, + references: CVec, + elapsed_time: f64, + interrupt: Option>, + error: Option>, +} + +impl From for CConversationResponseOwned { + fn from(v: ConversationResponse) -> Self { + let ConversationResponse { + chat_uid, + message_id, + status, + answer, + references, + elapsed_time, + interrupt, + error, + } = v; + Self { + chat_uid: chat_uid.into(), + message_id: message_id.into(), + status: status.into(), + answer: answer.into(), + // `references` is `Option>`; there's no FFI-level + // distinction between "absent" and "empty" here, both surface as + // `num_references == 0`. + references: references.unwrap_or_default().into(), + elapsed_time, + interrupt: interrupt.map(CCow::new), + error: error.map(CCow::new), + } + } +} + +impl ToFFI for CConversationResponseOwned { + type FFIType = CConversationResponse; + + fn to_ffi_type(&self) -> Self::FFIType { + let CConversationResponseOwned { + chat_uid, + message_id, + status, + answer, + references, + elapsed_time, + interrupt, + error, + } = self; + CConversationResponse { + chat_uid: chat_uid.to_ffi_type(), + message_id: message_id.to_ffi_type(), + status: *status, + answer: answer.to_ffi_type(), + references: references.to_ffi_type(), + num_references: references.len(), + elapsed_time: *elapsed_time, + interrupt: interrupt + .as_ref() + .map(ToFFI::to_ffi_type) + .unwrap_or(std::ptr::null()), + error: error + .as_ref() + .map(ToFFI::to_ffi_type) + .unwrap_or(std::ptr::null()), + } + } +} + +/// Payload of a `ChatStarted` conversation stream event +#[repr(C)] +pub struct CChatStartedPayload { + /// Conversation identifier + pub chat_uid: *const c_char, + /// Message ID of this round + pub message_id: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CChatStartedPayloadOwned { + chat_uid: CString, + message_id: CString, +} + +impl From for CChatStartedPayloadOwned { + fn from(v: ChatStartedPayload) -> Self { + let ChatStartedPayload { + chat_uid, + message_id, + } = v; + Self { + chat_uid: chat_uid.into(), + message_id: message_id.into(), + } + } +} + +impl ToFFI for CChatStartedPayloadOwned { + type FFIType = CChatStartedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + CChatStartedPayload { + chat_uid: self.chat_uid.to_ffi_type(), + message_id: self.message_id.to_ffi_type(), + } + } +} + +/// Payload of a `Message` conversation stream event — an incremental text +/// chunk. This is the highest-frequency event; concatenate `text` fragments +/// in arrival order. +#[repr(C)] +pub struct CMessagePayload { + /// Incremental text fragment + pub text: *const c_char, + /// `answer` — final answer text; `think` — reasoning process; `process` + /// — stage progress description + pub message_type: *const c_char, + /// Identifier of the stream segment this fragment belongs to. Fragments + /// with the same `key` form one continuous block — group by `key` when + /// rendering + pub key: *const c_char, + /// Time this segment started, Unix timestamp in seconds + pub started_at: i64, + /// Stage identifier; only present when `message_type` is `"process"` + pub stage: *const c_char, + /// Stage title while running; only present when `message_type` is + /// `"process"` + pub stage_title: *const c_char, + /// Stage title after it finishes; only present when `message_type` is + /// `"process"` + pub stage_finished_title: *const c_char, + /// Extra payload attached to the fragment, as a JSON string; empty when + /// absent + pub outputs_json: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CMessagePayloadOwned { + text: CString, + message_type: CString, + key: CString, + started_at: i64, + stage: CString, + stage_title: CString, + stage_finished_title: CString, + outputs_json: CString, +} + +impl From for CMessagePayloadOwned { + fn from(v: MessagePayload) -> Self { + let MessagePayload { + text, + message_type, + key, + started_at, + stage, + stage_title, + stage_finished_title, + outputs, + } = v; + Self { + text: text.into(), + message_type: message_type.into(), + key: key.into(), + started_at, + stage: stage.into(), + stage_title: stage_title.into(), + stage_finished_title: stage_finished_title.into(), + outputs_json: outputs + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + } + } +} + +impl ToFFI for CMessagePayloadOwned { + type FFIType = CMessagePayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CMessagePayloadOwned { + text, + message_type, + key, + started_at, + stage, + stage_title, + stage_finished_title, + outputs_json, + } = self; + CMessagePayload { + text: text.to_ffi_type(), + message_type: message_type.to_ffi_type(), + key: key.to_ffi_type(), + started_at: *started_at, + stage: stage.to_ffi_type(), + stage_title: stage_title.to_ffi_type(), + stage_finished_title: stage_finished_title.to_ffi_type(), + outputs_json: outputs_json.to_ffi_type(), + } + } +} + +/// `inputs` of a `WorkflowStarted` conversation stream event +#[repr(C)] +pub struct CWorkflowStartedInputs { + /// ID of the owning conversation + pub chat_id: i64, + /// Conversation identifier + pub chat_uid: *const c_char, + /// Message ID of this round + pub message_id: *const c_char, + /// The question that was asked + pub query: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CWorkflowStartedInputsOwned { + chat_id: i64, + chat_uid: CString, + message_id: CString, + query: CString, +} + +impl From for CWorkflowStartedInputsOwned { + fn from(v: WorkflowStartedInputs) -> Self { + let WorkflowStartedInputs { + chat_id, + chat_uid, + message_id, + query, + } = v; + Self { + chat_id, + chat_uid: chat_uid.into(), + message_id: message_id.into(), + query: query.into(), + } + } +} + +impl ToFFI for CWorkflowStartedInputsOwned { + type FFIType = CWorkflowStartedInputs; + + fn to_ffi_type(&self) -> Self::FFIType { + let CWorkflowStartedInputsOwned { + chat_id, + chat_uid, + message_id, + query, + } = self; + CWorkflowStartedInputs { + chat_id: *chat_id, + chat_uid: chat_uid.to_ffi_type(), + message_id: message_id.to_ffi_type(), + query: query.to_ffi_type(), + } + } +} + +/// Payload of a `WorkflowStarted` conversation stream event, observed right +/// after `ChatStarted` on every run seen so far +#[repr(C)] +pub struct CWorkflowStartedPayload { + /// Whether this run's answer was served from a cache + pub hit_cache: bool, + /// Echoes the run's inputs + pub inputs: *const CWorkflowStartedInputs, + /// Unix timestamp in seconds + pub started_at: i64, + /// Internal workflow run ID + pub workflow_id: i64, +} + +#[derive(Debug)] +pub(crate) struct CWorkflowStartedPayloadOwned { + hit_cache: bool, + inputs: CCow, + started_at: i64, + workflow_id: i64, +} + +impl From for CWorkflowStartedPayloadOwned { + fn from(v: WorkflowStartedPayload) -> Self { + let WorkflowStartedPayload { + hit_cache, + inputs, + started_at, + workflow_id, + } = v; + Self { + hit_cache, + inputs: CCow::new(inputs), + started_at, + workflow_id, + } + } +} + +impl ToFFI for CWorkflowStartedPayloadOwned { + type FFIType = CWorkflowStartedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CWorkflowStartedPayloadOwned { + hit_cache, + inputs, + started_at, + workflow_id, + } = self; + CWorkflowStartedPayload { + hit_cache: *hit_cache, + inputs: inputs.to_ffi_type(), + started_at: *started_at, + workflow_id: *workflow_id, + } + } +} + +/// Payload of a `ThinkingStarted` conversation stream event — the Agent has +/// entered the reasoning phase (analyzing the question, planning tool +/// calls). Between this and `ThinkingFinished`, `Message` events with +/// `message_type == "think"` and tool-call events may arrive. +#[repr(C)] +pub struct CThinkingStartedPayload { + /// Start time, Unix timestamp in seconds + pub started_at: i64, +} + +#[derive(Debug)] +pub(crate) struct CThinkingStartedPayloadOwned { + started_at: i64, +} + +impl From for CThinkingStartedPayloadOwned { + fn from(v: ThinkingStartedPayload) -> Self { + let ThinkingStartedPayload { started_at } = v; + Self { started_at } + } +} + +impl ToFFI for CThinkingStartedPayloadOwned { + type FFIType = CThinkingStartedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + CThinkingStartedPayload { + started_at: self.started_at, + } + } +} + +/// Payload of a `ThinkingFinished` conversation stream event — the +/// reasoning phase is over; answer text (`Message` with `message_type == +/// "answer"`) follows. +#[repr(C)] +pub struct CThinkingFinishedPayload { + /// Finish time, Unix timestamp in seconds + pub finished_at: i64, + /// Reasoning duration in seconds + pub elapsed_time: i32, +} + +#[derive(Debug)] +pub(crate) struct CThinkingFinishedPayloadOwned { + finished_at: i64, + elapsed_time: i32, +} + +impl From for CThinkingFinishedPayloadOwned { + fn from(v: ThinkingFinishedPayload) -> Self { + let ThinkingFinishedPayload { + finished_at, + elapsed_time, + } = v; + Self { + finished_at, + elapsed_time, + } + } +} + +impl ToFFI for CThinkingFinishedPayloadOwned { + type FFIType = CThinkingFinishedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + CThinkingFinishedPayload { + finished_at: self.finished_at, + elapsed_time: self.elapsed_time, + } + } +} + +/// Payload of a `NodeToolUseStarted` conversation stream event — an +/// ordinary tool call has started. Match it to its `NodeToolUseFinished` +/// counterpart by `tool_use_id`. +#[repr(C)] +pub struct CNodeToolUseStartedPayload { + /// Unique ID of this call; matches the finished event + pub tool_use_id: *const c_char, + /// Localized display name of the tool + pub tool_name: *const c_char, + /// Locale-stable tool identifier; use this for logic keyed on the tool + /// kind + pub tool_func_name: *const c_char, + /// Call arguments as a JSON string + pub tool_args: *const c_char, + /// Progress text suitable for direct display, e.g. `"Searching the + /// web…"` + pub tips: *const c_char, + /// Short tags accompanying `tips`; may be empty + pub tip_chips: *const *const c_char, + /// Number of tags in `tip_chips` + pub num_tip_chips: usize, + /// Round number. Calls in the same round (same `iteration`) run in + /// parallel + pub iteration: i32, + /// Start time, Unix timestamp in seconds + pub started_at: i64, +} + +#[derive(Debug)] +pub(crate) struct CNodeToolUseStartedPayloadOwned { + tool_use_id: CString, + tool_name: CString, + tool_func_name: CString, + tool_args: CString, + tips: CString, + tip_chips: CVec, + iteration: i32, + started_at: i64, +} + +impl From for CNodeToolUseStartedPayloadOwned { + fn from(v: NodeToolUseStartedPayload) -> Self { + let NodeToolUseStartedPayload { + tool_use_id, + tool_name, + tool_func_name, + tool_args, + tips, + tip_chips, + iteration, + started_at, + } = v; + Self { + tool_use_id: tool_use_id.into(), + tool_name: tool_name.into(), + tool_func_name: tool_func_name.into(), + tool_args: tool_args.into(), + tips: tips.into(), + tip_chips: tip_chips.into(), + iteration, + started_at, + } + } +} + +impl ToFFI for CNodeToolUseStartedPayloadOwned { + type FFIType = CNodeToolUseStartedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CNodeToolUseStartedPayloadOwned { + tool_use_id, + tool_name, + tool_func_name, + tool_args, + tips, + tip_chips, + iteration, + started_at, + } = self; + CNodeToolUseStartedPayload { + tool_use_id: tool_use_id.to_ffi_type(), + tool_name: tool_name.to_ffi_type(), + tool_func_name: tool_func_name.to_ffi_type(), + tool_args: tool_args.to_ffi_type(), + tips: tips.to_ffi_type(), + tip_chips: tip_chips.to_ffi_type(), + num_tip_chips: tip_chips.len(), + iteration: *iteration, + started_at: *started_at, + } + } +} + +/// `outputs` of a `NodeToolUseFinished` conversation stream event — only +/// carries fields meant for display +#[repr(C)] +pub struct CNodeToolUseOutputs { + /// Sources referenced by the tool result + pub references: *const CReference, + /// Number of references + pub num_references: usize, + /// Domains of the referenced sources + pub reference_domains: *const *const c_char, + /// Number of reference domains + pub num_reference_domains: usize, + /// The query the tool executed; empty when absent + pub query: *const c_char, + /// Raw response text of the tool; empty when absent + pub text: *const c_char, + /// Parsed request arguments, as a JSON string; empty when absent + pub tool_args_json: *const c_char, + /// Structured result, as a JSON string; present only for selected + /// tools, empty when absent + pub data_json: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CNodeToolUseOutputsOwned { + references: CVec, + reference_domains: CVec, + query: CString, + text: CString, + tool_args_json: CString, + data_json: CString, +} + +impl From for CNodeToolUseOutputsOwned { + fn from(v: NodeToolUseOutputs) -> Self { + let NodeToolUseOutputs { + references, + reference_domains, + query, + text, + tool_args, + data, + } = v; + Self { + references: references.unwrap_or_default().into(), + reference_domains: reference_domains.unwrap_or_default().into(), + query: query.unwrap_or_default().into(), + text: text.unwrap_or_default().into(), + tool_args_json: tool_args + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + data_json: data + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + } + } +} + +impl ToFFI for CNodeToolUseOutputsOwned { + type FFIType = CNodeToolUseOutputs; + + fn to_ffi_type(&self) -> Self::FFIType { + let CNodeToolUseOutputsOwned { + references, + reference_domains, + query, + text, + tool_args_json, + data_json, + } = self; + CNodeToolUseOutputs { + references: references.to_ffi_type(), + num_references: references.len(), + reference_domains: reference_domains.to_ffi_type(), + num_reference_domains: reference_domains.len(), + query: query.to_ffi_type(), + text: text.to_ffi_type(), + tool_args_json: tool_args_json.to_ffi_type(), + data_json: data_json.to_ffi_type(), + } + } +} + +/// Payload of a `NodeToolUseFinished` conversation stream event — the tool +/// call has ended. +#[repr(C)] +pub struct CNodeToolUseFinishedPayload { + /// Matches the `tool_use_id` of the started event + pub tool_use_id: *const c_char, + /// `succeeded` / `failed` + pub status: *const c_char, + /// Error description on failure + pub error: *const c_char, + /// Call duration in seconds + pub elapsed_time: f64, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Localized display name + pub tool_name: *const c_char, + /// Locale-stable tool identifier + pub tool_func_name: *const c_char, + /// Call arguments as a JSON string + pub tool_args: *const c_char, + /// Tool category + pub tool_type: *const c_char, + /// Progress text + pub tips: *const c_char, + /// Short tags; may be empty + pub tip_chips: *const *const c_char, + /// Number of tags in `tip_chips` + pub num_tip_chips: usize, + /// Round number + pub iteration: i32, + /// `true` if the call happened during the thinking phase + pub is_thinking: bool, + /// Filtered call results, for display + pub outputs: *const CNodeToolUseOutputs, +} + +#[derive(Debug)] +pub(crate) struct CNodeToolUseFinishedPayloadOwned { + tool_use_id: CString, + status: CString, + error: CString, + elapsed_time: f64, + started_at: i64, + tool_name: CString, + tool_func_name: CString, + tool_args: CString, + tool_type: CString, + tips: CString, + tip_chips: CVec, + iteration: i32, + is_thinking: bool, + outputs: CCow, +} + +impl From for CNodeToolUseFinishedPayloadOwned { + fn from(v: NodeToolUseFinishedPayload) -> Self { + let NodeToolUseFinishedPayload { + tool_use_id, + status, + error, + elapsed_time, + started_at, + tool_name, + tool_func_name, + tool_args, + tool_type, + tips, + tip_chips, + iteration, + is_thinking, + outputs, + } = v; + Self { + tool_use_id: tool_use_id.into(), + status: status.into(), + error: error.into(), + elapsed_time, + started_at, + tool_name: tool_name.into(), + tool_func_name: tool_func_name.into(), + tool_args: tool_args.into(), + tool_type: tool_type.into(), + tips: tips.into(), + tip_chips: tip_chips.into(), + iteration, + is_thinking, + outputs: CCow::new(outputs), + } + } +} + +impl ToFFI for CNodeToolUseFinishedPayloadOwned { + type FFIType = CNodeToolUseFinishedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CNodeToolUseFinishedPayloadOwned { + tool_use_id, + status, + error, + elapsed_time, + started_at, + tool_name, + tool_func_name, + tool_args, + tool_type, + tips, + tip_chips, + iteration, + is_thinking, + outputs, + } = self; + CNodeToolUseFinishedPayload { + tool_use_id: tool_use_id.to_ffi_type(), + status: status.to_ffi_type(), + error: error.to_ffi_type(), + elapsed_time: *elapsed_time, + started_at: *started_at, + tool_name: tool_name.to_ffi_type(), + tool_func_name: tool_func_name.to_ffi_type(), + tool_args: tool_args.to_ffi_type(), + tool_type: tool_type.to_ffi_type(), + tips: tips.to_ffi_type(), + tip_chips: tip_chips.to_ffi_type(), + num_tip_chips: tip_chips.len(), + iteration: *iteration, + is_thinking: *is_thinking, + outputs: outputs.to_ffi_type(), + } + } +} + +/// Payload of a `SubagentStarted` conversation stream event. When the Agent +/// spawns a subagent to work on a sub-task, the subagent's lifecycle is +/// reported with this dedicated event family instead of `NodeToolUse*`. +#[repr(C)] +pub struct CSubagentStartedPayload { + /// ID of the node that spawned the subagent + pub node_id: *const c_char, + /// Unique ID of this spawn; matches the finished event + pub tool_use_id: *const c_char, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Goal assigned to the subagent + pub goal: *const c_char, + /// Full task prompt given to the subagent + pub prompt: *const c_char, + /// Subagent identifier; may be empty + pub subagent_id: *const c_char, + /// Tools granted to the subagent, as a JSON array string; empty when + /// absent + pub tools_json: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CSubagentStartedPayloadOwned { + node_id: CString, + tool_use_id: CString, + started_at: i64, + goal: CString, + prompt: CString, + subagent_id: CString, + tools_json: CString, +} + +impl From for CSubagentStartedPayloadOwned { + fn from(v: SubagentStartedPayload) -> Self { + let SubagentStartedPayload { + node_id, + tool_use_id, + started_at, + goal, + prompt, + subagent_id, + tools, + } = v; + Self { + node_id: node_id.into(), + tool_use_id: tool_use_id.into(), + started_at, + goal: goal.into(), + prompt: prompt.into(), + subagent_id: subagent_id.into(), + tools_json: serde_json::to_string(&tools).unwrap_or_default().into(), + } + } +} + +impl ToFFI for CSubagentStartedPayloadOwned { + type FFIType = CSubagentStartedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CSubagentStartedPayloadOwned { + node_id, + tool_use_id, + started_at, + goal, + prompt, + subagent_id, + tools_json, + } = self; + CSubagentStartedPayload { + node_id: node_id.to_ffi_type(), + tool_use_id: tool_use_id.to_ffi_type(), + started_at: *started_at, + goal: goal.to_ffi_type(), + prompt: prompt.to_ffi_type(), + subagent_id: subagent_id.to_ffi_type(), + tools_json: tools_json.to_ffi_type(), + } + } +} + +/// Payload of a `SubagentProgress` conversation stream event, emitted every +/// time the subagent calls one of its own tools. Use it to render a live +/// timeline inside the subagent card. +#[repr(C)] +pub struct CSubagentProgressPayload { + /// ID of the node that spawned the subagent + pub node_id: *const c_char, + /// `tool_use_id` of the owning `SubagentStarted` event + pub parent_tool_call_id: *const c_char, + /// Name of the tool the subagent called + pub subagent_tool_name: *const c_char, + /// Arguments of that call, as a JSON string + pub subagent_tool_args: *const c_char, + /// Status of that call: `running` / `succeeded` / `failed` + pub subagent_status: *const c_char, + /// Duration of that call in milliseconds + pub subagent_duration_ms: i64, + /// The subagent's internal round number + pub subagent_iteration: i32, + /// Start time, Unix timestamp in seconds + pub started_at: i64, +} + +#[derive(Debug)] +pub(crate) struct CSubagentProgressPayloadOwned { + node_id: CString, + parent_tool_call_id: CString, + subagent_tool_name: CString, + subagent_tool_args: CString, + subagent_status: CString, + subagent_duration_ms: i64, + subagent_iteration: i32, + started_at: i64, +} + +impl From for CSubagentProgressPayloadOwned { + fn from(v: SubagentProgressPayload) -> Self { + let SubagentProgressPayload { + node_id, + parent_tool_call_id, + subagent_tool_name, + subagent_tool_args, + subagent_status, + subagent_duration_ms, + subagent_iteration, + started_at, + } = v; + Self { + node_id: node_id.into(), + parent_tool_call_id: parent_tool_call_id.into(), + subagent_tool_name: subagent_tool_name.into(), + subagent_tool_args: subagent_tool_args.into(), + subagent_status: subagent_status.into(), + subagent_duration_ms, + subagent_iteration, + started_at, + } + } +} + +impl ToFFI for CSubagentProgressPayloadOwned { + type FFIType = CSubagentProgressPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CSubagentProgressPayloadOwned { + node_id, + parent_tool_call_id, + subagent_tool_name, + subagent_tool_args, + subagent_status, + subagent_duration_ms, + subagent_iteration, + started_at, + } = self; + CSubagentProgressPayload { + node_id: node_id.to_ffi_type(), + parent_tool_call_id: parent_tool_call_id.to_ffi_type(), + subagent_tool_name: subagent_tool_name.to_ffi_type(), + subagent_tool_args: subagent_tool_args.to_ffi_type(), + subagent_status: subagent_status.to_ffi_type(), + subagent_duration_ms: *subagent_duration_ms, + subagent_iteration: *subagent_iteration, + started_at: *started_at, + } + } +} + +/// `outputs` of a `SubagentFinished` conversation stream event +#[repr(C)] +pub struct CSubagentOutputs { + /// The goal that was assigned to the subagent; empty when absent + pub goal: *const c_char, + /// The subagent's result; empty when absent + pub result: *const c_char, + /// Timeline of tool calls the subagent made, as a JSON array string; + /// empty when absent + pub subagent_tools_json: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CSubagentOutputsOwned { + goal: CString, + result: CString, + subagent_tools_json: CString, +} + +impl From for CSubagentOutputsOwned { + fn from(v: SubagentOutputs) -> Self { + let SubagentOutputs { + goal, + result, + subagent_tools, + } = v; + Self { + goal: goal.unwrap_or_default().into(), + result: result.unwrap_or_default().into(), + subagent_tools_json: subagent_tools + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + } + } +} + +impl ToFFI for CSubagentOutputsOwned { + type FFIType = CSubagentOutputs; + + fn to_ffi_type(&self) -> Self::FFIType { + let CSubagentOutputsOwned { + goal, + result, + subagent_tools_json, + } = self; + CSubagentOutputs { + goal: goal.to_ffi_type(), + result: result.to_ffi_type(), + subagent_tools_json: subagent_tools_json.to_ffi_type(), + } + } +} + +/// Payload of a `SubagentFinished` conversation stream event +#[repr(C)] +pub struct CSubagentFinishedPayload { + /// ID of the node that spawned the subagent + pub node_id: *const c_char, + /// Matches the `tool_use_id` of `SubagentStarted` + pub tool_use_id: *const c_char, + /// `succeeded` / `failed` + pub status: *const c_char, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Total subagent duration in seconds + pub elapsed_time: f64, + /// Error description on failure + pub error: *const c_char, + /// Subagent result: `goal`, `result`, and the timeline of tool calls it + /// made + pub outputs: *const CSubagentOutputs, +} + +#[derive(Debug)] +pub(crate) struct CSubagentFinishedPayloadOwned { + node_id: CString, + tool_use_id: CString, + status: CString, + started_at: i64, + elapsed_time: f64, + error: CString, + outputs: CCow, +} + +impl From for CSubagentFinishedPayloadOwned { + fn from(v: SubagentFinishedPayload) -> Self { + let SubagentFinishedPayload { + node_id, + tool_use_id, + status, + started_at, + elapsed_time, + error, + outputs, + } = v; + Self { + node_id: node_id.into(), + tool_use_id: tool_use_id.into(), + status: status.into(), + started_at, + elapsed_time, + error: error.into(), + outputs: CCow::new(outputs), + } + } +} + +impl ToFFI for CSubagentFinishedPayloadOwned { + type FFIType = CSubagentFinishedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CSubagentFinishedPayloadOwned { + node_id, + tool_use_id, + status, + started_at, + elapsed_time, + error, + outputs, + } = self; + CSubagentFinishedPayload { + node_id: node_id.to_ffi_type(), + tool_use_id: tool_use_id.to_ffi_type(), + status: status.to_ffi_type(), + started_at: *started_at, + elapsed_time: *elapsed_time, + error: error.to_ffi_type(), + outputs: outputs.to_ffi_type(), + } + } +} + +/// Payload of an `AgentToolStarted` conversation stream event. When the +/// Agent delegates to another Agent as a tool, that inner run is reported +/// with the `AgentTool*` family — the shape mirrors the subagent events. +#[repr(C)] +pub struct CAgentToolStartedPayload { + /// ID of the calling node + pub node_id: *const c_char, + /// Unique ID of this call; matches the finished event + pub tool_use_id: *const c_char, + /// Identifier of the Agent being called + pub agent_tool_name: *const c_char, + /// Display title; may be empty + pub title: *const c_char, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Call arguments as a JSON string + pub tool_args: *const c_char, + /// Localized display name + pub tool_name: *const c_char, + /// Progress text; may be empty + pub tips: *const c_char, + /// Short tags; may be empty + pub tip_chips: *const *const c_char, + /// Number of tags in `tip_chips` + pub num_tip_chips: usize, + /// `true` if called during the thinking phase + pub is_thinking: bool, +} + +#[derive(Debug)] +pub(crate) struct CAgentToolStartedPayloadOwned { + node_id: CString, + tool_use_id: CString, + agent_tool_name: CString, + title: CString, + started_at: i64, + tool_args: CString, + tool_name: CString, + tips: CString, + tip_chips: CVec, + is_thinking: bool, +} + +impl From for CAgentToolStartedPayloadOwned { + fn from(v: AgentToolStartedPayload) -> Self { + let AgentToolStartedPayload { + node_id, + tool_use_id, + agent_tool_name, + title, + started_at, + tool_args, + tool_name, + tips, + tip_chips, + is_thinking, + } = v; + Self { + node_id: node_id.into(), + tool_use_id: tool_use_id.into(), + agent_tool_name: agent_tool_name.into(), + title: title.into(), + started_at, + tool_args: tool_args.into(), + tool_name: tool_name.into(), + tips: tips.into(), + tip_chips: tip_chips.into(), + is_thinking, + } + } +} + +impl ToFFI for CAgentToolStartedPayloadOwned { + type FFIType = CAgentToolStartedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CAgentToolStartedPayloadOwned { + node_id, + tool_use_id, + agent_tool_name, + title, + started_at, + tool_args, + tool_name, + tips, + tip_chips, + is_thinking, + } = self; + CAgentToolStartedPayload { + node_id: node_id.to_ffi_type(), + tool_use_id: tool_use_id.to_ffi_type(), + agent_tool_name: agent_tool_name.to_ffi_type(), + title: title.to_ffi_type(), + started_at: *started_at, + tool_args: tool_args.to_ffi_type(), + tool_name: tool_name.to_ffi_type(), + tips: tips.to_ffi_type(), + tip_chips: tip_chips.to_ffi_type(), + num_tip_chips: tip_chips.len(), + is_thinking: *is_thinking, + } + } +} + +/// Payload of an `AgentToolProgress` conversation stream event, emitted for +/// each inner tool call the delegated Agent makes. +#[repr(C)] +pub struct CAgentToolProgressPayload { + /// ID of the calling node + pub node_id: *const c_char, + /// `tool_use_id` of the owning `AgentToolStarted` event + pub parent_tool_call_id: *const c_char, + /// Identifier of the Agent being called + pub agent_tool_name: *const c_char, + /// Name of the inner tool the delegated Agent called + pub inner_tool_name: *const c_char, + /// Arguments of that inner call, as a JSON string + pub inner_tool_args: *const c_char, + /// Status of the inner call: `running` / `succeeded` / `failed` + pub status: *const c_char, + /// Duration of the inner call in milliseconds + pub duration_ms: i64, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// `true` if during the thinking phase + pub is_thinking: bool, +} + +#[derive(Debug)] +pub(crate) struct CAgentToolProgressPayloadOwned { + node_id: CString, + parent_tool_call_id: CString, + agent_tool_name: CString, + inner_tool_name: CString, + inner_tool_args: CString, + status: CString, + duration_ms: i64, + started_at: i64, + is_thinking: bool, +} + +impl From for CAgentToolProgressPayloadOwned { + fn from(v: AgentToolProgressPayload) -> Self { + let AgentToolProgressPayload { + node_id, + parent_tool_call_id, + agent_tool_name, + inner_tool_name, + inner_tool_args, + status, + duration_ms, + started_at, + is_thinking, + } = v; + Self { + node_id: node_id.into(), + parent_tool_call_id: parent_tool_call_id.into(), + agent_tool_name: agent_tool_name.into(), + inner_tool_name: inner_tool_name.into(), + inner_tool_args: inner_tool_args.into(), + status: status.into(), + duration_ms, + started_at, + is_thinking, + } + } +} + +impl ToFFI for CAgentToolProgressPayloadOwned { + type FFIType = CAgentToolProgressPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CAgentToolProgressPayloadOwned { + node_id, + parent_tool_call_id, + agent_tool_name, + inner_tool_name, + inner_tool_args, + status, + duration_ms, + started_at, + is_thinking, + } = self; + CAgentToolProgressPayload { + node_id: node_id.to_ffi_type(), + parent_tool_call_id: parent_tool_call_id.to_ffi_type(), + agent_tool_name: agent_tool_name.to_ffi_type(), + inner_tool_name: inner_tool_name.to_ffi_type(), + inner_tool_args: inner_tool_args.to_ffi_type(), + status: status.to_ffi_type(), + duration_ms: *duration_ms, + started_at: *started_at, + is_thinking: *is_thinking, + } + } +} + +/// Payload of an `AgentToolFinished` conversation stream event +#[repr(C)] +pub struct CAgentToolFinishedPayload { + /// ID of the calling node + pub node_id: *const c_char, + /// Matches the `tool_use_id` of `AgentToolStarted` + pub tool_use_id: *const c_char, + /// Identifier of the Agent being called + pub agent_tool_name: *const c_char, + /// `succeeded` / `failed` + pub status: *const c_char, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Total duration in seconds + pub elapsed_time: f64, + /// Error description on failure + pub error: *const c_char, + /// Call arguments as a JSON string + pub tool_args: *const c_char, + /// Result of the delegated Agent, as a JSON string; empty when absent + pub outputs_json: *const c_char, + /// Tool category + pub tool_type: *const c_char, + /// Progress text; may be empty + pub tips: *const c_char, + /// Short tags; may be empty + pub tip_chips: *const *const c_char, + /// Number of tags in `tip_chips` + pub num_tip_chips: usize, + /// `true` if during the thinking phase + pub is_thinking: bool, +} + +#[derive(Debug)] +pub(crate) struct CAgentToolFinishedPayloadOwned { + node_id: CString, + tool_use_id: CString, + agent_tool_name: CString, + status: CString, + started_at: i64, + elapsed_time: f64, + error: CString, + tool_args: CString, + outputs_json: CString, + tool_type: CString, + tips: CString, + tip_chips: CVec, + is_thinking: bool, +} + +impl From for CAgentToolFinishedPayloadOwned { + fn from(v: AgentToolFinishedPayload) -> Self { + let AgentToolFinishedPayload { + node_id, + tool_use_id, + agent_tool_name, + status, + started_at, + elapsed_time, + error, + tool_args, + outputs, + tool_type, + tips, + tip_chips, + is_thinking, + } = v; + Self { + node_id: node_id.into(), + tool_use_id: tool_use_id.into(), + agent_tool_name: agent_tool_name.into(), + status: status.into(), + started_at, + elapsed_time, + error: error.into(), + tool_args: tool_args.into(), + outputs_json: outputs + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + tool_type: tool_type.into(), + tips: tips.into(), + tip_chips: tip_chips.into(), + is_thinking, + } + } +} + +impl ToFFI for CAgentToolFinishedPayloadOwned { + type FFIType = CAgentToolFinishedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CAgentToolFinishedPayloadOwned { + node_id, + tool_use_id, + agent_tool_name, + status, + started_at, + elapsed_time, + error, + tool_args, + outputs_json, + tool_type, + tips, + tip_chips, + is_thinking, + } = self; + CAgentToolFinishedPayload { + node_id: node_id.to_ffi_type(), + tool_use_id: tool_use_id.to_ffi_type(), + agent_tool_name: agent_tool_name.to_ffi_type(), + status: status.to_ffi_type(), + started_at: *started_at, + elapsed_time: *elapsed_time, + error: error.to_ffi_type(), + tool_args: tool_args.to_ffi_type(), + outputs_json: outputs_json.to_ffi_type(), + tool_type: tool_type.to_ffi_type(), + tips: tips.to_ffi_type(), + tip_chips: tip_chips.to_ffi_type(), + num_tip_chips: tip_chips.len(), + is_thinking: *is_thinking, + } + } +} + +/// Payload of a `QueryMasked` conversation stream event — sensitive content +/// in the user query was masked before processing. Display `masked_query` +/// instead of the original query. +#[repr(C)] +pub struct CQueryMaskedPayload { + /// The original user query + pub raw_query: *const c_char, + /// The masked query + pub masked_query: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CQueryMaskedPayloadOwned { + raw_query: CString, + masked_query: CString, +} + +impl From for CQueryMaskedPayloadOwned { + fn from(v: QueryMaskedPayload) -> Self { + let QueryMaskedPayload { + raw_query, + masked_query, + } = v; + Self { + raw_query: raw_query.into(), + masked_query: masked_query.into(), + } + } +} + +impl ToFFI for CQueryMaskedPayloadOwned { + type FFIType = CQueryMaskedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CQueryMaskedPayloadOwned { + raw_query, + masked_query, + } = self; + CQueryMaskedPayload { + raw_query: raw_query.to_ffi_type(), + masked_query: masked_query.to_ffi_type(), + } + } +} + +/// Payload of a `PlanChanged` conversation stream event — the Agent created +/// or updated its task plan. +#[repr(C)] +pub struct CPlanChangedPayload { + /// ID of the planning node + pub node_id: *const c_char, + /// Time of the change, Unix timestamp in seconds + pub started_at: i64, + /// The current plan content, as a JSON string; empty when absent + pub outputs_json: *const c_char, + /// Identifies the planning tool + pub tool_name: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CPlanChangedPayloadOwned { + node_id: CString, + started_at: i64, + outputs_json: CString, + tool_name: CString, +} + +impl From for CPlanChangedPayloadOwned { + fn from(v: PlanChangedPayload) -> Self { + let PlanChangedPayload { + node_id, + started_at, + outputs, + tool_name, + } = v; + Self { + node_id: node_id.into(), + started_at, + outputs_json: outputs + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + tool_name: tool_name.into(), + } + } +} + +impl ToFFI for CPlanChangedPayloadOwned { + type FFIType = CPlanChangedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CPlanChangedPayloadOwned { + node_id, + started_at, + outputs_json, + tool_name, + } = self; + CPlanChangedPayload { + node_id: node_id.to_ffi_type(), + started_at: *started_at, + outputs_json: outputs_json.to_ffi_type(), + tool_name: tool_name.to_ffi_type(), + } + } +} + +/// Payload of a `ContextCompressStarted` conversation stream event, marking +/// the start of a context-compression pass triggered by a long +/// conversation. Unlike other events, `started_at` here is an RFC 3339 +/// string. +#[repr(C)] +pub struct CContextCompressStartedPayload { + /// Start time, RFC 3339 + pub started_at: *const c_char, + /// Compression input summary, as a JSON string; empty when absent + pub inputs_json: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CContextCompressStartedPayloadOwned { + started_at: CString, + inputs_json: CString, +} + +impl From for CContextCompressStartedPayloadOwned { + fn from(v: ContextCompressStartedPayload) -> Self { + let ContextCompressStartedPayload { started_at, inputs } = v; + Self { + started_at: started_at.into(), + inputs_json: inputs + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + } + } +} + +impl ToFFI for CContextCompressStartedPayloadOwned { + type FFIType = CContextCompressStartedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CContextCompressStartedPayloadOwned { + started_at, + inputs_json, + } = self; + CContextCompressStartedPayload { + started_at: started_at.to_ffi_type(), + inputs_json: inputs_json.to_ffi_type(), + } + } +} + +/// Payload of a `ContextCompressFinished` conversation stream event. Unlike +/// other events, `created_at` here is an RFC 3339 string. +#[repr(C)] +pub struct CContextCompressFinishedPayload { + /// Finish time, RFC 3339 + pub created_at: *const c_char, + /// Compression input summary, as a JSON string; empty when absent + pub inputs_json: *const c_char, + /// Compression result summary, as a JSON string; empty when absent + pub outputs_json: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CContextCompressFinishedPayloadOwned { + created_at: CString, + inputs_json: CString, + outputs_json: CString, +} + +impl From for CContextCompressFinishedPayloadOwned { + fn from(v: ContextCompressFinishedPayload) -> Self { + let ContextCompressFinishedPayload { + created_at, + inputs, + outputs, + } = v; + Self { + created_at: created_at.into(), + inputs_json: inputs + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + outputs_json: outputs + .map(|v| serde_json::to_string(&v).unwrap_or_default()) + .unwrap_or_default() + .into(), + } + } +} + +impl ToFFI for CContextCompressFinishedPayloadOwned { + type FFIType = CContextCompressFinishedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CContextCompressFinishedPayloadOwned { + created_at, + inputs_json, + outputs_json, + } = self; + CContextCompressFinishedPayload { + created_at: created_at.to_ffi_type(), + inputs_json: inputs_json.to_ffi_type(), + outputs_json: outputs_json.to_ffi_type(), + } + } +} + +/// Payload of a `ChatFinished` conversation stream event, observed once all +/// `Message` events for this round have been sent, shortly before +/// `WorkflowFinished` +#[repr(C)] +pub struct CChatFinishedPayload { + /// ID of the owning conversation + pub chat_id: i64, + /// Conversation identifier + pub chat_uid: *const c_char, + /// Message ID of this round + pub message_id: *const c_char, + /// Empty string in every run observed so far + pub error: *const c_char, + /// Empty string in every run observed so far + pub error_message: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CChatFinishedPayloadOwned { + chat_id: i64, + chat_uid: CString, + message_id: CString, + error: CString, + error_message: CString, +} + +impl From for CChatFinishedPayloadOwned { + fn from(v: ChatFinishedPayload) -> Self { + let ChatFinishedPayload { + chat_id, + chat_uid, + message_id, + error, + error_message, + } = v; + Self { + chat_id, + chat_uid: chat_uid.into(), + message_id: message_id.into(), + error: error.into(), + error_message: error_message.into(), + } + } +} + +impl ToFFI for CChatFinishedPayloadOwned { + type FFIType = CChatFinishedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CChatFinishedPayloadOwned { + chat_id, + chat_uid, + message_id, + error, + error_message, + } = self; + CChatFinishedPayload { + chat_id: *chat_id, + chat_uid: chat_uid.to_ffi_type(), + message_id: message_id.to_ffi_type(), + error: error.to_ffi_type(), + error_message: error_message.to_ffi_type(), + } + } +} + +/// Payload of a `ChatTitleUpdated` conversation stream event — the server +/// auto-generates a short title for the conversation as a UI convenience. +/// Can arrive before *or* after `WorkflowFinished`; not tied to the run's +/// outcome. +#[repr(C)] +pub struct CChatTitleUpdatedPayload { + /// ID of the owning conversation + pub chat_id: i64, + /// Conversation identifier + pub chat_uid: *const c_char, + /// Where the title came from, e.g. `"ai_generated"` + pub source: *const c_char, + /// The new (possibly truncated) title + pub title: *const c_char, + /// Unix timestamp in seconds + pub updated_at: i64, +} + +#[derive(Debug)] +pub(crate) struct CChatTitleUpdatedPayloadOwned { + chat_id: i64, + chat_uid: CString, + source: CString, + title: CString, + updated_at: i64, +} + +impl From for CChatTitleUpdatedPayloadOwned { + fn from(v: ChatTitleUpdatedPayload) -> Self { + let ChatTitleUpdatedPayload { + chat_id, + chat_uid, + source, + title, + updated_at, + } = v; + Self { + chat_id, + chat_uid: chat_uid.into(), + source: source.into(), + title: title.into(), + updated_at, + } + } +} + +impl ToFFI for CChatTitleUpdatedPayloadOwned { + type FFIType = CChatTitleUpdatedPayload; + + fn to_ffi_type(&self) -> Self::FFIType { + let CChatTitleUpdatedPayloadOwned { + chat_id, + chat_uid, + source, + title, + updated_at, + } = self; + CChatTitleUpdatedPayload { + chat_id: *chat_id, + chat_uid: chat_uid.to_ffi_type(), + source: source.to_ffi_type(), + title: title.to_ffi_type(), + updated_at: *updated_at, + } + } +} + +/// One event observed while streaming `lb_agent_context_conversation_streamed` +/// or `lb_agent_context_continue_conversation_streamed`. +/// +/// This is a tagged union: `kind` tells you which one field below is +/// non-null; all others are always null. When `kind` is `Ping` (a +/// heartbeat with no payload), every field below is null. +#[repr(C)] +pub struct CConversationStreamEvent { + /// Discriminant, tells you which field below is populated + pub kind: CConversationStreamEventType, + /// Non-null when `kind` is `ChatStarted` + pub chat_started: *const CChatStartedPayload, + /// Non-null when `kind` is `WorkflowStarted`, observed right after + /// `ChatStarted` on every run seen so far + pub workflow_started: *const CWorkflowStartedPayload, + /// Non-null when `kind` is `Message` + pub message: *const CMessagePayload, + /// Non-null when `kind` is `ThinkingStarted`, the Agent entering the + /// reasoning phase + pub thinking_started: *const CThinkingStartedPayload, + /// Non-null when `kind` is `ThinkingFinished`, the reasoning phase + /// ending + pub thinking_finished: *const CThinkingFinishedPayload, + /// Non-null when `kind` is `NodeToolUseStarted`, an ordinary tool call + /// starting + pub node_tool_use_started: *const CNodeToolUseStartedPayload, + /// Non-null when `kind` is `NodeToolUseFinished`, an ordinary tool call + /// ending + pub node_tool_use_finished: *const CNodeToolUseFinishedPayload, + /// Non-null when `kind` is `SubagentStarted`, the Agent spawning a + /// subagent to work on a sub-task + pub subagent_started: *const CSubagentStartedPayload, + /// Non-null when `kind` is `SubagentProgress`, the subagent calling one + /// of its own tools + pub subagent_progress: *const CSubagentProgressPayload, + /// Non-null when `kind` is `SubagentFinished`, the subagent finishing + /// its sub-task + pub subagent_finished: *const CSubagentFinishedPayload, + /// Non-null when `kind` is `AgentToolStarted`, the Agent delegating to + /// another Agent as a tool + pub agent_tool_started: *const CAgentToolStartedPayload, + /// Non-null when `kind` is `AgentToolProgress`, the delegated Agent + /// calling one of its own tools + pub agent_tool_progress: *const CAgentToolProgressPayload, + /// Non-null when `kind` is `AgentToolFinished`, the delegated Agent's + /// run finishing + pub agent_tool_finished: *const CAgentToolFinishedPayload, + /// Non-null when `kind` is `HumanInteractionRequired`, carrying the + /// run's outcome for an interrupted run. Unlike `WorkflowFinished`, this + /// is emitted instead of (never alongside) `WorkflowFinished` for the + /// same run + pub human_interaction_required: *const CConversationResponse, + /// Non-null when `kind` is `QueryMasked`, sensitive content in the user + /// query having been masked before processing + pub query_masked: *const CQueryMaskedPayload, + /// Non-null when `kind` is `PlanChanged`, the Agent creating or + /// updating its task plan + pub plan_changed: *const CPlanChangedPayload, + /// Non-null when `kind` is `ContextCompressStarted`, a + /// context-compression pass starting + pub context_compress_started: *const CContextCompressStartedPayload, + /// Non-null when `kind` is `ContextCompressFinished`, a + /// context-compression pass finishing + pub context_compress_finished: *const CContextCompressFinishedPayload, + /// Non-null when `kind` is `ChatFinished`, observed once all `Message` + /// events for this round have been sent + pub chat_finished: *const CChatFinishedPayload, + /// Non-null when `kind` is `WorkflowFinished`, carrying the run's + /// outcome — not necessarily the last event of the stream, since the + /// server may still emit a few more housekeeping events before actually + /// closing the connection + pub workflow_finished: *const CConversationResponse, + /// Non-null when `kind` is `ChatTitleUpdated`, the server auto-generating + /// a short title for the conversation + pub chat_title_updated: *const CChatTitleUpdatedPayload, + /// Non-null when `kind` is `Other`; the SSE envelope's `event` field (the + /// event type name) + pub other_event: *const c_char, + /// Non-null when `kind` is `Other`; raw JSON of an event type not + /// recognized by this SDK version + pub other_json: *const c_char, +} + +pub(crate) enum CConversationStreamEventOwned { + ChatStarted(CCow), + WorkflowStarted(CCow), + Message(CCow), + Ping, + ThinkingStarted(CCow), + ThinkingFinished(CCow), + NodeToolUseStarted(CCow), + NodeToolUseFinished(CCow), + SubagentStarted(CCow), + SubagentProgress(CCow), + SubagentFinished(CCow), + AgentToolStarted(CCow), + AgentToolProgress(CCow), + AgentToolFinished(CCow), + HumanInteractionRequired(CCow), + QueryMasked(CCow), + PlanChanged(CCow), + ContextCompressStarted(CCow), + ContextCompressFinished(CCow), + ChatFinished(CCow), + WorkflowFinished(CCow), + ChatTitleUpdated(CCow), + Other { event: CString, json: CString }, +} + +impl From for CConversationStreamEventOwned { + fn from(v: ConversationStreamEvent) -> Self { + match v { + ConversationStreamEvent::ChatStarted(payload) => Self::ChatStarted(CCow::new(payload)), + ConversationStreamEvent::WorkflowStarted(payload) => { + Self::WorkflowStarted(CCow::new(payload)) + } + ConversationStreamEvent::Message(payload) => Self::Message(CCow::new(payload)), + ConversationStreamEvent::Ping => Self::Ping, + ConversationStreamEvent::ThinkingStarted(payload) => { + Self::ThinkingStarted(CCow::new(payload)) + } + ConversationStreamEvent::ThinkingFinished(payload) => { + Self::ThinkingFinished(CCow::new(payload)) + } + ConversationStreamEvent::NodeToolUseStarted(payload) => { + Self::NodeToolUseStarted(CCow::new(payload)) + } + ConversationStreamEvent::NodeToolUseFinished(payload) => { + Self::NodeToolUseFinished(CCow::new(payload)) + } + ConversationStreamEvent::SubagentStarted(payload) => { + Self::SubagentStarted(CCow::new(payload)) + } + ConversationStreamEvent::SubagentProgress(payload) => { + Self::SubagentProgress(CCow::new(payload)) + } + ConversationStreamEvent::SubagentFinished(payload) => { + Self::SubagentFinished(CCow::new(payload)) + } + ConversationStreamEvent::AgentToolStarted(payload) => { + Self::AgentToolStarted(CCow::new(payload)) + } + ConversationStreamEvent::AgentToolProgress(payload) => { + Self::AgentToolProgress(CCow::new(payload)) + } + ConversationStreamEvent::AgentToolFinished(payload) => { + Self::AgentToolFinished(CCow::new(payload)) + } + // Like `WorkflowFinished`, this carries a synthesized + // `ConversationResponse` — it's the terminal event for + // interrupted runs, which never emit `WorkflowFinished` at all. + ConversationStreamEvent::HumanInteractionRequired(resp) => { + Self::HumanInteractionRequired(CCow::new(resp)) + } + ConversationStreamEvent::QueryMasked(payload) => Self::QueryMasked(CCow::new(payload)), + ConversationStreamEvent::PlanChanged(payload) => Self::PlanChanged(CCow::new(payload)), + ConversationStreamEvent::ContextCompressStarted(payload) => { + Self::ContextCompressStarted(CCow::new(payload)) + } + ConversationStreamEvent::ContextCompressFinished(payload) => { + Self::ContextCompressFinished(CCow::new(payload)) + } + ConversationStreamEvent::ChatFinished(payload) => { + Self::ChatFinished(CCow::new(payload)) + } + ConversationStreamEvent::WorkflowFinished(resp) => { + Self::WorkflowFinished(CCow::new(resp)) + } + ConversationStreamEvent::ChatTitleUpdated(payload) => { + Self::ChatTitleUpdated(CCow::new(payload)) + } + // `Other` carries an arbitrary `serde_json::Value` (events from + // future SDK versions we don't recognize yet) — re-serialize it + // to a JSON string so C callers can still inspect it, alongside + // the discriminating `event` type name. + ConversationStreamEvent::Other { event, data } => Self::Other { + event: event.into(), + json: serde_json::to_string(&data).unwrap_or_default().into(), + }, + } + } +} + +impl ToFFI for CConversationStreamEventOwned { + type FFIType = CConversationStreamEvent; + + fn to_ffi_type(&self) -> Self::FFIType { + // Every field besides `kind` defaults to null; each arm below + // overrides only the one field relevant to its `kind`. + fn base(kind: CConversationStreamEventType) -> CConversationStreamEvent { + CConversationStreamEvent { + kind, + chat_started: std::ptr::null(), + workflow_started: std::ptr::null(), + message: std::ptr::null(), + thinking_started: std::ptr::null(), + thinking_finished: std::ptr::null(), + node_tool_use_started: std::ptr::null(), + node_tool_use_finished: std::ptr::null(), + subagent_started: std::ptr::null(), + subagent_progress: std::ptr::null(), + subagent_finished: std::ptr::null(), + agent_tool_started: std::ptr::null(), + agent_tool_progress: std::ptr::null(), + agent_tool_finished: std::ptr::null(), + human_interaction_required: std::ptr::null(), + query_masked: std::ptr::null(), + plan_changed: std::ptr::null(), + context_compress_started: std::ptr::null(), + context_compress_finished: std::ptr::null(), + chat_finished: std::ptr::null(), + workflow_finished: std::ptr::null(), + chat_title_updated: std::ptr::null(), + other_event: std::ptr::null(), + other_json: std::ptr::null(), + } + } + + match self { + Self::ChatStarted(payload) => CConversationStreamEvent { + chat_started: payload.to_ffi_type(), + ..base(CConversationStreamEventType::ChatStarted) + }, + Self::WorkflowStarted(payload) => CConversationStreamEvent { + workflow_started: payload.to_ffi_type(), + ..base(CConversationStreamEventType::WorkflowStarted) + }, + Self::Message(payload) => CConversationStreamEvent { + message: payload.to_ffi_type(), + ..base(CConversationStreamEventType::Message) + }, + Self::Ping => base(CConversationStreamEventType::Ping), + Self::ThinkingStarted(payload) => CConversationStreamEvent { + thinking_started: payload.to_ffi_type(), + ..base(CConversationStreamEventType::ThinkingStarted) + }, + Self::ThinkingFinished(payload) => CConversationStreamEvent { + thinking_finished: payload.to_ffi_type(), + ..base(CConversationStreamEventType::ThinkingFinished) + }, + Self::NodeToolUseStarted(payload) => CConversationStreamEvent { + node_tool_use_started: payload.to_ffi_type(), + ..base(CConversationStreamEventType::NodeToolUseStarted) + }, + Self::NodeToolUseFinished(payload) => CConversationStreamEvent { + node_tool_use_finished: payload.to_ffi_type(), + ..base(CConversationStreamEventType::NodeToolUseFinished) + }, + Self::SubagentStarted(payload) => CConversationStreamEvent { + subagent_started: payload.to_ffi_type(), + ..base(CConversationStreamEventType::SubagentStarted) + }, + Self::SubagentProgress(payload) => CConversationStreamEvent { + subagent_progress: payload.to_ffi_type(), + ..base(CConversationStreamEventType::SubagentProgress) + }, + Self::SubagentFinished(payload) => CConversationStreamEvent { + subagent_finished: payload.to_ffi_type(), + ..base(CConversationStreamEventType::SubagentFinished) + }, + Self::AgentToolStarted(payload) => CConversationStreamEvent { + agent_tool_started: payload.to_ffi_type(), + ..base(CConversationStreamEventType::AgentToolStarted) + }, + Self::AgentToolProgress(payload) => CConversationStreamEvent { + agent_tool_progress: payload.to_ffi_type(), + ..base(CConversationStreamEventType::AgentToolProgress) + }, + Self::AgentToolFinished(payload) => CConversationStreamEvent { + agent_tool_finished: payload.to_ffi_type(), + ..base(CConversationStreamEventType::AgentToolFinished) + }, + Self::HumanInteractionRequired(resp) => CConversationStreamEvent { + human_interaction_required: resp.to_ffi_type(), + ..base(CConversationStreamEventType::HumanInteractionRequired) + }, + Self::QueryMasked(payload) => CConversationStreamEvent { + query_masked: payload.to_ffi_type(), + ..base(CConversationStreamEventType::QueryMasked) + }, + Self::PlanChanged(payload) => CConversationStreamEvent { + plan_changed: payload.to_ffi_type(), + ..base(CConversationStreamEventType::PlanChanged) + }, + Self::ContextCompressStarted(payload) => CConversationStreamEvent { + context_compress_started: payload.to_ffi_type(), + ..base(CConversationStreamEventType::ContextCompressStarted) + }, + Self::ContextCompressFinished(payload) => CConversationStreamEvent { + context_compress_finished: payload.to_ffi_type(), + ..base(CConversationStreamEventType::ContextCompressFinished) + }, + Self::ChatFinished(payload) => CConversationStreamEvent { + chat_finished: payload.to_ffi_type(), + ..base(CConversationStreamEventType::ChatFinished) + }, + Self::WorkflowFinished(resp) => CConversationStreamEvent { + workflow_finished: resp.to_ffi_type(), + ..base(CConversationStreamEventType::WorkflowFinished) + }, + Self::ChatTitleUpdated(payload) => CConversationStreamEvent { + chat_title_updated: payload.to_ffi_type(), + ..base(CConversationStreamEventType::ChatTitleUpdated) + }, + Self::Other { event, json } => CConversationStreamEvent { + other_event: event.to_ffi_type(), + other_json: json.to_ffi_type(), + ..base(CConversationStreamEventType::Other) + }, + } + } +} + +/// One answer to a [`CInterrupt`] question, used as an entry of +/// [`CAnswersByToolCallEntry::answers`] +#[repr(C)] +pub struct CAnswerQuestion { + /// Question text, must match `CQuestion::question` verbatim + pub question: *const c_char, + /// Your answer text + pub answer: *const c_char, +} + +/// Answers for one `tool_call_id`, used as an entry of the `answers` array of +/// `lb_agent_context_continue_conversation`/ +/// `lb_agent_context_continue_conversation_streamed`. +/// +/// The Rust core's `AnswersByToolCall` is a +/// `HashMap>` keyed by `tool_call_id`, then by +/// question text. Since C has no native map type, it's flattened into an +/// array of `(tool_call_id, [(question, answer)])` entries — this array of +/// `CAnswersByToolCallEntry` mirrors the outer map, and each entry's +/// `answers` array (of `CAnswerQuestion`) mirrors the inner map. +#[repr(C)] +pub struct CAnswersByToolCallEntry { + /// Tool call ID, see [`CInterrupt::tool_call_id`] + pub tool_call_id: *const c_char, + /// Answers to the questions raised for this tool call + pub answers: *const CAnswerQuestion, + /// Number of answers + pub num_answers: usize, +} diff --git a/c/src/lib.rs b/c/src/lib.rs index 4a8fccdaf9..b1d9d2bb55 100644 --- a/c/src/lib.rs +++ b/c/src/lib.rs @@ -1,5 +1,6 @@ #![allow(unsafe_op_in_unsafe_fn)] +mod agent_context; mod alert_context; mod asset_context; mod async_call; diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 66ccf831ef..89c86bf445 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -4,6 +4,7 @@ set(SOURCES src/config.cpp src/content_context.cpp src/decimal.cpp + src/agent_context.cpp src/alert_context.cpp src/dca_context.cpp src/sharelist_context.cpp diff --git a/cpp/include/agent_context.hpp b/cpp/include/agent_context.hpp new file mode 100644 index 0000000000..ffd838a4c7 --- /dev/null +++ b/cpp/include/agent_context.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include + +#include "async_result.hpp" +#include "callback.hpp" +#include "config.hpp" +#include "push.hpp" +#include "types.hpp" + +typedef struct lb_agent_context_t lb_agent_context_t; + +namespace longbridge { +namespace agent { + +/// Answers keyed by `tool_call_id`, then by question text — mirrors the +/// Rust core's `HashMap>` (see the C +/// layer's `lb_answers_by_tool_call_entry_t`/`lb_answer_question_t`, which +/// flatten this nested map into arrays since C has no native map type). +using AnswersByToolCall = std::map>; + +/// AI Agent conversation context. +class AgentContext +{ +private: + const lb_agent_context_t* ctx_; + +public: + AgentContext(); + AgentContext(const lb_agent_context_t* ctx); + AgentContext(const AgentContext& ctx); + AgentContext(AgentContext&& ctx); + ~AgentContext(); + + AgentContext& operator=(const AgentContext& ctx); + + /// Create an AgentContext from a Config. + static AgentContext create(const Config& config); + + /// List the Workspaces the current account belongs to. + void workspaces(AsyncCallback callback) const; + + /// List the Agents in the specified Workspace. + void agents(const std::string& workspace_id, + const std::optional& opts, + AsyncCallback callback) const; + + /// Start a conversation with the specified Agent, blocking until the run + /// succeeds, is interrupted, or fails. + /// + /// @param chat_uid Existing conversation identifier to continue within + /// (`std::nullopt` to start a brand-new conversation) + void conversation(const std::string& agent_id, + const std::string& query, + const std::optional& chat_uid, + AsyncCallback callback) const; + + /// Resume an interrupted conversation, blocking until the run succeeds, + /// is interrupted again, or fails. + /// + /// @param answers Answers keyed by `tool_call_id`, see AnswersByToolCall + void continue_conversation(const std::string& agent_id, + const std::string& chat_uid, + const std::string& message_id, + const AnswersByToolCall& answers, + AsyncCallback callback) const; + + /// Start a conversation with the specified Agent, calling `on_event` for + /// every run-progress event observed over SSE. A succeeded, failed, or + /// stopped run carries its outcome in a `WorkflowFinished` event; an + /// interrupted run carries it in a `HumanInteractionRequired` event + /// instead — the two are never emitted for the same run. Either way, the + /// carrying event isn't necessarily the last one seen — the server may + /// still emit a few more housekeeping events (e.g. `ChatTitleUpdated`) + /// before actually closing the connection. Once the stream truly ends, + /// `callback` is invoked with the final ConversationResponse — same as + /// `conversation`, just arrived at via the streamed path. + /// + /// @param chat_uid Existing conversation identifier to continue within + /// (`std::nullopt` to start a brand-new conversation) + void conversation_streamed( + const std::string& agent_id, + const std::string& query, + const std::optional& chat_uid, + PushCallback on_event, + AsyncCallback callback) const; + + /// Resume an interrupted conversation, calling `on_event` for every + /// run-progress event observed over SSE, then `callback` with the final + /// ConversationResponse once the stream ends — same shape as + /// `conversation_streamed`. + /// + /// @param answers Answers keyed by `tool_call_id`, see AnswersByToolCall + void continue_conversation_streamed( + const std::string& agent_id, + const std::string& chat_uid, + const std::string& message_id, + const AnswersByToolCall& answers, + PushCallback on_event, + AsyncCallback callback) const; +}; + +} // namespace agent +} // namespace longbridge diff --git a/cpp/include/longbridge.hpp b/cpp/include/longbridge.hpp index 99c0cbd56e..f222501316 100644 --- a/cpp/include/longbridge.hpp +++ b/cpp/include/longbridge.hpp @@ -1,5 +1,6 @@ #pragma once +#include "agent_context.hpp" #include "asset_context.hpp" #include "config.hpp" #include "decimal.hpp" diff --git a/cpp/include/types.hpp b/cpp/include/types.hpp index c161ddedc0..146a1a0720 100644 --- a/cpp/include/types.hpp +++ b/cpp/include/types.hpp @@ -3653,4 +3653,731 @@ struct SharelistDetail } // namespace sharelist +namespace agent { + +/// A Workspace the current account belongs to. +struct Workspace +{ + /// Workspace ID + std::string id; + /// Workspace name + std::string name; + /// Creation time, Unix timestamp in seconds + int64_t created_at; + /// Last updated time, Unix timestamp in seconds + int64_t updated_at; +}; + +/// Response for AgentContext::workspaces. +struct WorkspacesResponse +{ + /// Workspaces the current account belongs to + std::vector workspaces; +}; + +/// An Agent in a Workspace. +struct Agent +{ + /// Agent UID, used as the path parameter of AgentContext::conversation + std::string uid; + /// Agent name + std::string name; + /// Agent description + std::string description; + /// Agent mode, e.g. "chat" + std::string mode; + /// Icon URL + std::string icon; + /// Whether published; only published Agents can start conversations + bool is_published; + /// Publish time, Unix timestamp in seconds; 0 if unpublished + int64_t published_at; + /// Creation time, Unix timestamp in seconds + int64_t created_at; + /// Last updated time, Unix timestamp in seconds + int64_t updated_at; +}; + +/// Response for AgentContext::agents. +struct AgentsResponse +{ + /// Agent list + std::vector agents; + /// Total number of matching Agents + int32_t total; +}; + +/// Options for AgentContext::agents (all fields optional). +struct GetAgentsOptions +{ + /// Page number, starts at 1 + std::optional page; + /// Page size + std::optional limit; + /// Fuzzy search by Agent name + std::optional name; +}; + +/// A source referenced by the answer. +struct Reference +{ + /// Reference index + int32_t index; + /// Reference title + std::string title; + /// Reference URL + std::string url; +}; + +/// One option of a Question. +struct QuestionOption +{ + /// Option text + std::string description; +}; + +/// One question the Agent needs you to answer. +struct Question +{ + /// Question text + std::string question; + /// Options; empty means free-form answer + std::vector options; + /// Whether multiple options may be selected + bool multi_select; +}; + +/// Present when a conversation run is interrupted, waiting for +/// AgentContext::continue_conversation. +struct Interrupt +{ + /// ID of the node that triggered the interrupt + std::string node_id; + /// Tool call ID of this inquiry; used as the answer key when continuing + std::string tool_call_id; + /// Questions you need to answer + std::vector questions; + /// ID of the paused message + int64_t message_id; + /// ID of the owning conversation + int64_t chat_id; +}; + +/// Present when a conversation run failed. +struct AgentError +{ + /// Error code + int32_t code; + /// Error message + std::string message; +}; + +/// Final run status of a conversation. +enum class ConversationStatus +{ + /// The run completed successfully + Succeeded, + /// The run is paused, waiting for AgentContext::continue_conversation + Interrupted, + /// The run failed + Failed, + /// The run was stopped + Stopped, +}; + +/// Response for AgentContext::conversation, AgentContext::continue_conversation, +/// and the final result of the streamed counterparts. +struct ConversationResponse +{ + /// Conversation identifier, used for follow-up questions and + /// troubleshooting + std::string chat_uid; + /// Message ID of this round + std::string message_id; + /// Final run status + ConversationStatus status; + /// Final answer text; valid when status is ConversationStatus::Succeeded + std::string answer; + /// Sources referenced by the answer + std::vector references; + /// Run duration in seconds + double elapsed_time; + /// Present only when status is ConversationStatus::Interrupted + std::optional interrupt; + /// Present only when the run failed + std::optional error; +}; + +/// Payload of a ChatStarted conversation stream event. +struct ChatStartedPayload +{ + /// Conversation identifier + std::string chat_uid; + /// Message ID of this round + std::string message_id; +}; + +/// Payload of a Message conversation stream event — an incremental text +/// chunk. This is the highest-frequency event; concatenate text fragments +/// in arrival order. +struct MessagePayload +{ + /// Incremental text fragment + std::string text; + /// answer — final answer text; think — reasoning process; process — + /// stage progress description + std::string message_type; + /// Identifier of the stream segment this fragment belongs to. Fragments + /// with the same key form one continuous block — group by key when + /// rendering + std::string key; + /// Time this segment started, Unix timestamp in seconds + int64_t started_at; + /// Stage identifier; only present when message_type is "process" + std::string stage; + /// Stage title while running; only present when message_type is + /// "process" + std::string stage_title; + /// Stage title after it finishes; only present when message_type is + /// "process" + std::string stage_finished_title; + /// Extra payload attached to the fragment, as a JSON string; empty when + /// absent + std::string outputs_json; +}; + +/// `inputs` of a WorkflowStarted conversation stream event. +struct WorkflowStartedInputs +{ + /// ID of the owning conversation + int64_t chat_id; + /// Conversation identifier + std::string chat_uid; + /// Message ID of this round + std::string message_id; + /// The question that was asked + std::string query; +}; + +/// Payload of a WorkflowStarted conversation stream event, observed right +/// after ChatStarted on every run seen so far. +struct WorkflowStartedPayload +{ + /// Whether this run's answer was served from a cache + bool hit_cache; + /// Echoes the run's inputs + WorkflowStartedInputs inputs; + /// Unix timestamp in seconds + int64_t started_at; + /// Internal workflow run ID + int64_t workflow_id; +}; + +/// Payload of a ThinkingStarted conversation stream event — the Agent has +/// entered the reasoning phase (analyzing the question, planning tool +/// calls). Between this and ThinkingFinished, Message events with +/// message_type == "think" and tool-call events may arrive. +struct ThinkingStartedPayload +{ + /// Start time, Unix timestamp in seconds + int64_t started_at; +}; + +/// Payload of a ThinkingFinished conversation stream event — the reasoning +/// phase is over; answer text (Message with message_type == "answer") +/// follows. +struct ThinkingFinishedPayload +{ + /// Finish time, Unix timestamp in seconds + int64_t finished_at; + /// Reasoning duration in seconds + int32_t elapsed_time; +}; + +/// Payload of a NodeToolUseStarted conversation stream event — an ordinary +/// tool call has started. Match it to its NodeToolUseFinished counterpart +/// by tool_use_id. +struct NodeToolUseStartedPayload +{ + /// Unique ID of this call; matches the finished event + std::string tool_use_id; + /// Localized display name of the tool + std::string tool_name; + /// Locale-stable tool identifier; use this for logic keyed on the tool + /// kind + std::string tool_func_name; + /// Call arguments as a JSON string + std::string tool_args; + /// Progress text suitable for direct display, e.g. "Searching the + /// web..." + std::string tips; + /// Short tags accompanying tips; may be empty + std::vector tip_chips; + /// Round number. Calls in the same round (same iteration) run in + /// parallel + int32_t iteration; + /// Start time, Unix timestamp in seconds + int64_t started_at; +}; + +/// outputs of a NodeToolUseFinished conversation stream event — only +/// carries fields meant for display. +struct NodeToolUseOutputs +{ + /// Sources referenced by the tool result + std::vector references; + /// Domains of the referenced sources + std::vector reference_domains; + /// The query the tool executed; empty when absent + std::string query; + /// Raw response text of the tool; empty when absent + std::string text; + /// Parsed request arguments, as a JSON string; empty when absent + std::string tool_args_json; + /// Structured result, as a JSON string; present only for selected tools, + /// empty when absent + std::string data_json; +}; + +/// Payload of a NodeToolUseFinished conversation stream event — the tool +/// call has ended. +struct NodeToolUseFinishedPayload +{ + /// Matches the tool_use_id of the started event + std::string tool_use_id; + /// succeeded / failed + std::string status; + /// Error description on failure + std::string error; + /// Call duration in seconds + double elapsed_time; + /// Start time, Unix timestamp in seconds + int64_t started_at; + /// Localized display name + std::string tool_name; + /// Locale-stable tool identifier + std::string tool_func_name; + /// Call arguments as a JSON string + std::string tool_args; + /// Tool category + std::string tool_type; + /// Progress text + std::string tips; + /// Short tags; may be empty + std::vector tip_chips; + /// Round number + int32_t iteration; + /// true if the call happened during the thinking phase + bool is_thinking; + /// Filtered call results, for display + NodeToolUseOutputs outputs; +}; + +/// Payload of a SubagentStarted conversation stream event. When the Agent +/// spawns a subagent to work on a sub-task, the subagent's lifecycle is +/// reported with this dedicated event family instead of NodeToolUse*. +struct SubagentStartedPayload +{ + /// ID of the node that spawned the subagent + std::string node_id; + /// Unique ID of this spawn; matches the finished event + std::string tool_use_id; + /// Start time, Unix timestamp in seconds + int64_t started_at; + /// Goal assigned to the subagent + std::string goal; + /// Full task prompt given to the subagent + std::string prompt; + /// Subagent identifier; may be empty + std::string subagent_id; + /// Tools granted to the subagent, as a JSON array string; empty when + /// absent + std::string tools_json; +}; + +/// Payload of a SubagentProgress conversation stream event, emitted every +/// time the subagent calls one of its own tools. Use it to render a live +/// timeline inside the subagent card. +struct SubagentProgressPayload +{ + /// ID of the node that spawned the subagent + std::string node_id; + /// tool_use_id of the owning SubagentStarted event + std::string parent_tool_call_id; + /// Name of the tool the subagent called + std::string subagent_tool_name; + /// Arguments of that call, as a JSON string + std::string subagent_tool_args; + /// Status of that call: running / succeeded / failed + std::string subagent_status; + /// Duration of that call in milliseconds + int64_t subagent_duration_ms; + /// The subagent's internal round number + int32_t subagent_iteration; + /// Start time, Unix timestamp in seconds + int64_t started_at; +}; + +/// outputs of a SubagentFinished conversation stream event. +struct SubagentOutputs +{ + /// The goal that was assigned to the subagent; empty when absent + std::string goal; + /// The subagent's result; empty when absent + std::string result; + /// Timeline of tool calls the subagent made, as a JSON array string; + /// empty when absent + std::string subagent_tools_json; +}; + +/// Payload of a SubagentFinished conversation stream event. +struct SubagentFinishedPayload +{ + /// ID of the node that spawned the subagent + std::string node_id; + /// Matches the tool_use_id of SubagentStarted + std::string tool_use_id; + /// succeeded / failed + std::string status; + /// Start time, Unix timestamp in seconds + int64_t started_at; + /// Total subagent duration in seconds + double elapsed_time; + /// Error description on failure + std::string error; + /// Subagent result: goal, result, and the timeline of tool calls it made + SubagentOutputs outputs; +}; + +/// Payload of an AgentToolStarted conversation stream event. When the Agent +/// delegates to another Agent as a tool, that inner run is reported with +/// the AgentTool* family — the shape mirrors the subagent events. +struct AgentToolStartedPayload +{ + /// ID of the calling node + std::string node_id; + /// Unique ID of this call; matches the finished event + std::string tool_use_id; + /// Identifier of the Agent being called + std::string agent_tool_name; + /// Display title; may be empty + std::string title; + /// Start time, Unix timestamp in seconds + int64_t started_at; + /// Call arguments as a JSON string + std::string tool_args; + /// Localized display name + std::string tool_name; + /// Progress text; may be empty + std::string tips; + /// Short tags; may be empty + std::vector tip_chips; + /// true if called during the thinking phase + bool is_thinking; +}; + +/// Payload of an AgentToolProgress conversation stream event, emitted for +/// each inner tool call the delegated Agent makes. +struct AgentToolProgressPayload +{ + /// ID of the calling node + std::string node_id; + /// tool_use_id of the owning AgentToolStarted event + std::string parent_tool_call_id; + /// Identifier of the Agent being called + std::string agent_tool_name; + /// Name of the inner tool the delegated Agent called + std::string inner_tool_name; + /// Arguments of that inner call, as a JSON string + std::string inner_tool_args; + /// Status of the inner call: running / succeeded / failed + std::string status; + /// Duration of the inner call in milliseconds + int64_t duration_ms; + /// Start time, Unix timestamp in seconds + int64_t started_at; + /// true if during the thinking phase + bool is_thinking; +}; + +/// Payload of an AgentToolFinished conversation stream event. +struct AgentToolFinishedPayload +{ + /// ID of the calling node + std::string node_id; + /// Matches the tool_use_id of AgentToolStarted + std::string tool_use_id; + /// Identifier of the Agent being called + std::string agent_tool_name; + /// succeeded / failed + std::string status; + /// Start time, Unix timestamp in seconds + int64_t started_at; + /// Total duration in seconds + double elapsed_time; + /// Error description on failure + std::string error; + /// Call arguments as a JSON string + std::string tool_args; + /// Result of the delegated Agent, as a JSON string; empty when absent + std::string outputs_json; + /// Tool category + std::string tool_type; + /// Progress text; may be empty + std::string tips; + /// Short tags; may be empty + std::vector tip_chips; + /// true if during the thinking phase + bool is_thinking; +}; + +/// Payload of a QueryMasked conversation stream event — sensitive content +/// in the user query was masked before processing. Display masked_query +/// instead of the original query. +struct QueryMaskedPayload +{ + /// The original user query + std::string raw_query; + /// The masked query + std::string masked_query; +}; + +/// Payload of a PlanChanged conversation stream event — the Agent created +/// or updated its task plan. +struct PlanChangedPayload +{ + /// ID of the planning node + std::string node_id; + /// Time of the change, Unix timestamp in seconds + int64_t started_at; + /// The current plan content, as a JSON string; empty when absent + std::string outputs_json; + /// Identifies the planning tool + std::string tool_name; +}; + +/// Payload of a ContextCompressStarted conversation stream event, marking +/// the start of a context-compression pass triggered by a long +/// conversation. Unlike other events, started_at here is an RFC 3339 +/// string. +struct ContextCompressStartedPayload +{ + /// Start time, RFC 3339 + std::string started_at; + /// Compression input summary, as a JSON string; empty when absent + std::string inputs_json; +}; + +/// Payload of a ContextCompressFinished conversation stream event. Unlike +/// other events, created_at here is an RFC 3339 string. +struct ContextCompressFinishedPayload +{ + /// Finish time, RFC 3339 + std::string created_at; + /// Compression input summary, as a JSON string; empty when absent + std::string inputs_json; + /// Compression result summary, as a JSON string; empty when absent + std::string outputs_json; +}; + +/// Payload of a ChatFinished conversation stream event, observed once all +/// Message events for this round have been sent, shortly before +/// WorkflowFinished. +struct ChatFinishedPayload +{ + /// ID of the owning conversation + int64_t chat_id; + /// Conversation identifier + std::string chat_uid; + /// Message ID of this round + std::string message_id; + /// Empty string in every run observed so far + std::string error; + /// Empty string in every run observed so far + std::string error_message; +}; + +/// Payload of a ChatTitleUpdated conversation stream event — the server +/// auto-generates a short title for the conversation as a UI convenience. +/// Can arrive before *or* after WorkflowFinished; not tied to the run's +/// outcome. +struct ChatTitleUpdatedPayload +{ + /// ID of the owning conversation + int64_t chat_id; + /// Conversation identifier + std::string chat_uid; + /// Where the title came from, e.g. "ai_generated" + std::string source; + /// The new (possibly truncated) title + std::string title; + /// Unix timestamp in seconds + int64_t updated_at; +}; + +/// Kind of a ConversationStreamEvent. Only the field matching this kind is +/// populated, the others are `std::nullopt`. +enum class ConversationStreamEventKind +{ + /// The run has started; `chat_started` is populated + ChatStarted, + /// Observed right after `ChatStarted` on every run seen so far; + /// `workflow_started` is populated + WorkflowStarted, + /// An incremental piece of the answer; `message` is populated + Message, + /// A heartbeat with no payload, observed at arbitrary points in the + /// stream (including in between `Message` chunks); every field below is + /// `std::nullopt` + Ping, + /// The Agent has entered the reasoning phase; `thinking_started` is + /// populated + ThinkingStarted, + /// The reasoning phase is over; `thinking_finished` is populated + ThinkingFinished, + /// An ordinary tool call has started; `node_tool_use_started` is + /// populated + NodeToolUseStarted, + /// An ordinary tool call has ended; `node_tool_use_finished` is + /// populated + NodeToolUseFinished, + /// The Agent has spawned a subagent to work on a sub-task; + /// `subagent_started` is populated + SubagentStarted, + /// The subagent has called one of its own tools; `subagent_progress` is + /// populated + SubagentProgress, + /// The subagent has finished its sub-task; `subagent_finished` is + /// populated + SubagentFinished, + /// The Agent has delegated to another Agent as a tool; + /// `agent_tool_started` is populated + AgentToolStarted, + /// The delegated Agent has called one of its own tools; + /// `agent_tool_progress` is populated + AgentToolProgress, + /// The delegated Agent's run has finished; `agent_tool_finished` is + /// populated + AgentToolFinished, + /// The run is paused: the Agent needs more information or confirmation + /// from you; `human_interaction_required` is populated. Unlike + /// `WorkflowFinished`, this is emitted instead of (never alongside) + /// `WorkflowFinished` for the same run + HumanInteractionRequired, + /// Sensitive content in the user query was masked before processing; + /// `query_masked` is populated + QueryMasked, + /// The Agent created or updated its task plan; `plan_changed` is + /// populated + PlanChanged, + /// A context-compression pass has started; `context_compress_started` + /// is populated + ContextCompressStarted, + /// The context-compression pass has finished; + /// `context_compress_finished` is populated + ContextCompressFinished, + /// Observed once all `Message` events for this round have been sent; + /// `chat_finished` is populated + ChatFinished, + /// The run finished successfully, with a failure, or stopped by the + /// user; `workflow_finished` is populated. Never emitted for an + /// interrupted run — see `HumanInteractionRequired` for that case + WorkflowFinished, + /// The server auto-generating a short title for the conversation; + /// `chat_title_updated` is populated + ChatTitleUpdated, + /// An event type not recognized by this SDK version; `other_json` is + /// populated with the raw event JSON + Other, +}; + +/// One event observed while streaming AgentContext::conversation_streamed or +/// AgentContext::continue_conversation_streamed. +/// +/// This mirrors the C layer's tagged-union `lb_conversation_stream_event_t`: +/// `kind` tells you which one of the payload fields below is populated (all +/// others are `std::nullopt`; when `kind` is `Ping`, none of them are). A +/// plain discriminated struct is used here (rather than `std::variant` or a +/// JSON library) since neither has any precedent elsewhere in this C++ +/// binding, and this shape converts directly from the C tagged union. +struct ConversationStreamEvent +{ + /// Discriminant, tells you which field below is populated + ConversationStreamEventKind kind; + /// Populated when `kind` is `ChatStarted` + std::optional chat_started; + /// Populated when `kind` is `WorkflowStarted`, observed right after + /// `ChatStarted` on every run seen so far + std::optional workflow_started; + /// Populated when `kind` is `Message` + std::optional message; + /// Populated when `kind` is `ThinkingStarted`, the Agent entering the + /// reasoning phase + std::optional thinking_started; + /// Populated when `kind` is `ThinkingFinished`, the reasoning phase + /// ending + std::optional thinking_finished; + /// Populated when `kind` is `NodeToolUseStarted`, an ordinary tool call + /// starting + std::optional node_tool_use_started; + /// Populated when `kind` is `NodeToolUseFinished`, an ordinary tool call + /// ending + std::optional node_tool_use_finished; + /// Populated when `kind` is `SubagentStarted`, the Agent spawning a + /// subagent to work on a sub-task + std::optional subagent_started; + /// Populated when `kind` is `SubagentProgress`, the subagent calling one + /// of its own tools + std::optional subagent_progress; + /// Populated when `kind` is `SubagentFinished`, the subagent finishing + /// its sub-task + std::optional subagent_finished; + /// Populated when `kind` is `AgentToolStarted`, the Agent delegating to + /// another Agent as a tool + std::optional agent_tool_started; + /// Populated when `kind` is `AgentToolProgress`, the delegated Agent + /// calling one of its own tools + std::optional agent_tool_progress; + /// Populated when `kind` is `AgentToolFinished`, the delegated Agent's + /// run finishing + std::optional agent_tool_finished; + /// Populated when `kind` is `HumanInteractionRequired`, carrying the + /// run's outcome for an interrupted run. Unlike `workflow_finished`, this + /// is populated instead of (never alongside) `workflow_finished` for the + /// same run + std::optional human_interaction_required; + /// Populated when `kind` is `QueryMasked`, sensitive content in the user + /// query having been masked before processing + std::optional query_masked; + /// Populated when `kind` is `PlanChanged`, the Agent creating or + /// updating its task plan + std::optional plan_changed; + /// Populated when `kind` is `ContextCompressStarted`, a + /// context-compression pass starting + std::optional context_compress_started; + /// Populated when `kind` is `ContextCompressFinished`, a + /// context-compression pass finishing + std::optional context_compress_finished; + /// Populated when `kind` is `ChatFinished`, observed once all `Message` + /// events for this round have been sent + std::optional chat_finished; + /// Populated when `kind` is `WorkflowFinished`, carrying the run's + /// outcome — not necessarily the last event of the stream, since the + /// server may still emit a few more housekeeping events before actually + /// closing the connection. Never populated for an interrupted run — see + /// `human_interaction_required` for that case + std::optional workflow_finished; + /// Populated when `kind` is `ChatTitleUpdated`, the server auto-generating + /// a short title for the conversation + std::optional chat_title_updated; + /// Populated when `kind` is `Other`; the SSE envelope's `event` field (the + /// event type name) + std::optional other_event; + /// Populated when `kind` is `Other`; raw JSON of an event type not + /// recognized by this SDK version + std::optional other_json; +}; + +} // namespace agent + } // namespace longbridge \ No newline at end of file diff --git a/cpp/src/agent_context.cpp b/cpp/src/agent_context.cpp new file mode 100644 index 0000000000..b9db8c1cad --- /dev/null +++ b/cpp/src/agent_context.cpp @@ -0,0 +1,327 @@ +#include "agent_context.hpp" +#include "convert.hpp" + +namespace longbridge { +namespace agent { + +using longbridge::convert::convert; + +namespace { + +/// Flattened storage for an `AnswersByToolCall`, matching the C layer's +/// array-of-entries shape (`lb_answers_by_tool_call_entry_t` / +/// `lb_answer_question_t`). The `entries`/`per_entry_answers` vectors are +/// reserved up front so pointers into already-pushed elements stay valid +/// while more are appended; all `const char*`s borrow from the original +/// `AnswersByToolCall`, so this must not outlive it. +struct AnswersFFI +{ + std::vector entries; + std::vector> per_entry_answers; +}; + +AnswersFFI +build_answers_ffi(const AnswersByToolCall& answers) +{ + AnswersFFI ffi; + ffi.entries.reserve(answers.size()); + ffi.per_entry_answers.reserve(answers.size()); + for (const auto& [tool_call_id, qa] : answers) { + std::vector qs; + qs.reserve(qa.size()); + for (const auto& [question, answer] : qa) { + qs.push_back(lb_answer_question_t{ question.c_str(), answer.c_str() }); + } + ffi.per_entry_answers.push_back(std::move(qs)); + const auto& stored = ffi.per_entry_answers.back(); + ffi.entries.push_back(lb_answers_by_tool_call_entry_t{ + tool_call_id.c_str(), stored.data(), stored.size() }); + } + return ffi; +} + +} // namespace + +AgentContext::AgentContext() + : ctx_(nullptr) +{ +} + +AgentContext::AgentContext(const lb_agent_context_t* ctx) +{ + ctx_ = ctx; + if (ctx_) { + lb_agent_context_retain(ctx_); + } +} + +AgentContext::AgentContext(const AgentContext& ctx) +{ + ctx_ = ctx.ctx_; + if (ctx_) { + lb_agent_context_retain(ctx_); + } +} + +AgentContext::AgentContext(AgentContext&& ctx) +{ + ctx_ = ctx.ctx_; + ctx.ctx_ = nullptr; +} + +AgentContext::~AgentContext() +{ + if (ctx_) { + lb_agent_context_release(ctx_); + } +} + +AgentContext& +AgentContext::operator=(const AgentContext& ctx) +{ + ctx_ = ctx.ctx_; + if (ctx_) { + lb_agent_context_retain(ctx_); + } + return *this; +} + +AgentContext +AgentContext::create(const Config& config) +{ + auto* ctx_ptr = lb_agent_context_new(config); + AgentContext ctx(ctx_ptr); + if (ctx_ptr) { + lb_agent_context_release(ctx_ptr); + } + return ctx; +} + +void +AgentContext::workspaces( + AsyncCallback callback) const +{ + lb_agent_context_workspaces( + ctx_, + [](auto res) { + auto callback_ptr = + callback::get_async_callback( + res->userdata); + AgentContext ctx((const lb_agent_context_t*)res->ctx); + Status status(res->error); + + if (status) { + WorkspacesResponse resp = + convert((const lb_workspaces_response_t*)res->data); + (*callback_ptr)(AsyncResult( + ctx, std::move(status), &resp)); + } else { + (*callback_ptr)(AsyncResult( + ctx, std::move(status), nullptr)); + } + }, + new AsyncCallback(callback)); +} + +void +AgentContext::agents( + const std::string& workspace_id, + const std::optional& opts, + AsyncCallback callback) const +{ + lb_get_agents_options_t opts2 = { nullptr, nullptr, nullptr }; + if (opts) { + opts2.page = opts->page ? &opts->page.value() : nullptr; + opts2.limit = opts->limit ? &opts->limit.value() : nullptr; + opts2.name = opts->name ? opts->name->c_str() : nullptr; + } + + lb_agent_context_agents( + ctx_, + workspace_id.c_str(), + &opts2, + [](auto res) { + auto callback_ptr = + callback::get_async_callback( + res->userdata); + AgentContext ctx((const lb_agent_context_t*)res->ctx); + Status status(res->error); + + if (status) { + AgentsResponse resp = convert((const lb_agents_response_t*)res->data); + (*callback_ptr)(AsyncResult( + ctx, std::move(status), &resp)); + } else { + (*callback_ptr)(AsyncResult( + ctx, std::move(status), nullptr)); + } + }, + new AsyncCallback(callback)); +} + +void +AgentContext::conversation( + const std::string& agent_id, + const std::string& query, + const std::optional& chat_uid, + AsyncCallback callback) const +{ + lb_agent_context_conversation( + ctx_, + agent_id.c_str(), + query.c_str(), + chat_uid ? chat_uid->c_str() : nullptr, + [](auto res) { + auto callback_ptr = + callback::get_async_callback( + res->userdata); + AgentContext ctx((const lb_agent_context_t*)res->ctx); + Status status(res->error); + + if (status) { + ConversationResponse resp = + convert((const lb_conversation_response_t*)res->data); + (*callback_ptr)(AsyncResult( + ctx, std::move(status), &resp)); + } else { + (*callback_ptr)(AsyncResult( + ctx, std::move(status), nullptr)); + } + }, + new AsyncCallback(callback)); +} + +void +AgentContext::continue_conversation( + const std::string& agent_id, + const std::string& chat_uid, + const std::string& message_id, + const AnswersByToolCall& answers, + AsyncCallback callback) const +{ + AnswersFFI ffi = build_answers_ffi(answers); + + lb_agent_context_continue_conversation( + ctx_, + agent_id.c_str(), + chat_uid.c_str(), + message_id.c_str(), + ffi.entries.empty() ? nullptr : ffi.entries.data(), + ffi.entries.size(), + [](auto res) { + auto callback_ptr = + callback::get_async_callback( + res->userdata); + AgentContext ctx((const lb_agent_context_t*)res->ctx); + Status status(res->error); + + if (status) { + ConversationResponse resp = + convert((const lb_conversation_response_t*)res->data); + (*callback_ptr)(AsyncResult( + ctx, std::move(status), &resp)); + } else { + (*callback_ptr)(AsyncResult( + ctx, std::move(status), nullptr)); + } + }, + new AsyncCallback(callback)); +} + +void +AgentContext::conversation_streamed( + const std::string& agent_id, + const std::string& query, + const std::optional& chat_uid, + PushCallback on_event, + AsyncCallback callback) const +{ + lb_agent_context_conversation_streamed( + ctx_, + agent_id.c_str(), + query.c_str(), + chat_uid ? chat_uid->c_str() : nullptr, + [](auto ctx, auto event, auto userdata) { + auto cb = + callback::get_push_callback( + userdata); + ConversationStreamEvent event2 = convert(event); + (*cb)(PushEvent( + AgentContext(ctx), &event2)); + }, + new PushCallback(on_event), + [](auto p) { + delete (PushCallback*)p; + }, + [](auto res) { + auto callback_ptr = + callback::get_async_callback( + res->userdata); + AgentContext ctx((const lb_agent_context_t*)res->ctx); + Status status(res->error); + + if (status) { + ConversationResponse resp = + convert((const lb_conversation_response_t*)res->data); + (*callback_ptr)(AsyncResult( + ctx, std::move(status), &resp)); + } else { + (*callback_ptr)(AsyncResult( + ctx, std::move(status), nullptr)); + } + }, + new AsyncCallback(callback)); +} + +void +AgentContext::continue_conversation_streamed( + const std::string& agent_id, + const std::string& chat_uid, + const std::string& message_id, + const AnswersByToolCall& answers, + PushCallback on_event, + AsyncCallback callback) const +{ + AnswersFFI ffi = build_answers_ffi(answers); + + lb_agent_context_continue_conversation_streamed( + ctx_, + agent_id.c_str(), + chat_uid.c_str(), + message_id.c_str(), + ffi.entries.empty() ? nullptr : ffi.entries.data(), + ffi.entries.size(), + [](auto ctx, auto event, auto userdata) { + auto cb = + callback::get_push_callback( + userdata); + ConversationStreamEvent event2 = convert(event); + (*cb)(PushEvent( + AgentContext(ctx), &event2)); + }, + new PushCallback(on_event), + [](auto p) { + delete (PushCallback*)p; + }, + [](auto res) { + auto callback_ptr = + callback::get_async_callback( + res->userdata); + AgentContext ctx((const lb_agent_context_t*)res->ctx); + Status status(res->error); + + if (status) { + ConversationResponse resp = + convert((const lb_conversation_response_t*)res->data); + (*callback_ptr)(AsyncResult( + ctx, std::move(status), &resp)); + } else { + (*callback_ptr)(AsyncResult( + ctx, std::move(status), nullptr)); + } + }, + new AsyncCallback(callback)); +} + +} // namespace agent +} // namespace longbridge diff --git a/cpp/src/convert.hpp b/cpp/src/convert.hpp index 873d82752b..e1eec637e7 100644 --- a/cpp/src/convert.hpp +++ b/cpp/src/convert.hpp @@ -2976,6 +2976,260 @@ inline sharelist::SharelistDetail convert(const lb_sharelist_detail_t* r) { return { convert(&r->sharelist), convert(&r->scopes) }; } +// ── AgentContext ────────────────────────────────────────────────── + +inline agent::Workspace convert(const lb_workspace_t* w) { + return { w->id, w->name, w->created_at, w->updated_at }; +} +inline agent::WorkspacesResponse convert(const lb_workspaces_response_t* r) { + std::vector workspaces; + for (size_t i = 0; i < r->num_workspaces; ++i) workspaces.push_back(convert(&r->workspaces[i])); + return { std::move(workspaces) }; +} +inline agent::Agent convert(const lb_agent_t* a) { + return { a->uid, a->name, a->description, a->mode, a->icon, a->is_published, + a->published_at, a->created_at, a->updated_at }; +} +inline agent::AgentsResponse convert(const lb_agents_response_t* r) { + std::vector agents; + for (size_t i = 0; i < r->num_agents; ++i) agents.push_back(convert(&r->agents[i])); + return { std::move(agents), r->total }; +} +inline agent::ConversationStatus convert(lb_conversation_status_t status) { + switch (status) { + case ConversationStatusSucceeded: + return agent::ConversationStatus::Succeeded; + case ConversationStatusInterrupted: + return agent::ConversationStatus::Interrupted; + case ConversationStatusFailed: + return agent::ConversationStatus::Failed; + case ConversationStatusStopped: + return agent::ConversationStatus::Stopped; + default: + throw std::invalid_argument("unreachable"); + } +} +inline agent::Reference convert(const lb_reference_t* r) { + return { r->index, r->title, r->url }; +} +inline agent::QuestionOption convert(const lb_question_option_t* o) { + return { o->description }; +} +inline agent::Question convert(const lb_question_t* q) { + std::vector options; + for (size_t i = 0; i < q->num_options; ++i) options.push_back(convert(&q->options[i])); + return { q->question, std::move(options), q->multi_select }; +} +inline agent::Interrupt convert(const lb_interrupt_t* i) { + std::vector questions; + for (size_t j = 0; j < i->num_questions; ++j) questions.push_back(convert(&i->questions[j])); + return { i->node_id, i->tool_call_id, std::move(questions), i->message_id, i->chat_id }; +} +inline agent::AgentError convert(const lb_agent_error_t* e) { + return { e->code, e->message }; +} +inline agent::ConversationResponse convert(const lb_conversation_response_t* r) { + std::vector references; + for (size_t i = 0; i < r->num_references; ++i) references.push_back(convert(&r->references[i])); + return { + r->chat_uid, + r->message_id, + convert(r->status), + r->answer, + std::move(references), + r->elapsed_time, + r->interrupt ? std::optional(convert(r->interrupt)) : std::nullopt, + r->error ? std::optional(convert(r->error)) : std::nullopt, + }; +} +inline agent::ChatStartedPayload convert(const lb_chat_started_payload_t* p) { + return { p->chat_uid, p->message_id }; +} +inline agent::MessagePayload convert(const lb_message_payload_t* p) { + return { p->text, p->message_type, p->key, p->started_at, p->stage, p->stage_title, + p->stage_finished_title, p->outputs_json }; +} +inline agent::WorkflowStartedInputs convert(const lb_workflow_started_inputs_t* i) { + return { i->chat_id, i->chat_uid, i->message_id, i->query }; +} +inline agent::WorkflowStartedPayload convert(const lb_workflow_started_payload_t* p) { + return { p->hit_cache, convert(p->inputs), p->started_at, p->workflow_id }; +} +inline agent::ThinkingStartedPayload convert(const lb_thinking_started_payload_t* p) { + return { p->started_at }; +} +inline agent::ThinkingFinishedPayload convert(const lb_thinking_finished_payload_t* p) { + return { p->finished_at, p->elapsed_time }; +} +inline agent::NodeToolUseStartedPayload convert(const lb_node_tool_use_started_payload_t* p) { + std::vector tip_chips(p->tip_chips, p->tip_chips + p->num_tip_chips); + return { p->tool_use_id, p->tool_name, p->tool_func_name, p->tool_args, p->tips, + std::move(tip_chips), p->iteration, p->started_at }; +} +inline agent::NodeToolUseOutputs convert(const lb_node_tool_use_outputs_t* o) { + std::vector references; + for (size_t i = 0; i < o->num_references; ++i) references.push_back(convert(&o->references[i])); + std::vector reference_domains(o->reference_domains, + o->reference_domains + o->num_reference_domains); + return { std::move(references), std::move(reference_domains), o->query, o->text, + o->tool_args_json, o->data_json }; +} +inline agent::NodeToolUseFinishedPayload convert(const lb_node_tool_use_finished_payload_t* p) { + std::vector tip_chips(p->tip_chips, p->tip_chips + p->num_tip_chips); + return { p->tool_use_id, p->status, p->error, p->elapsed_time, p->started_at, p->tool_name, + p->tool_func_name, p->tool_args, p->tool_type, p->tips, std::move(tip_chips), + p->iteration, p->is_thinking, convert(p->outputs) }; +} +inline agent::SubagentStartedPayload convert(const lb_subagent_started_payload_t* p) { + return { p->node_id, p->tool_use_id, p->started_at, p->goal, p->prompt, p->subagent_id, + p->tools_json }; +} +inline agent::SubagentProgressPayload convert(const lb_subagent_progress_payload_t* p) { + return { p->node_id, p->parent_tool_call_id, p->subagent_tool_name, p->subagent_tool_args, + p->subagent_status, p->subagent_duration_ms, p->subagent_iteration, p->started_at }; +} +inline agent::SubagentOutputs convert(const lb_subagent_outputs_t* o) { + return { o->goal, o->result, o->subagent_tools_json }; +} +inline agent::SubagentFinishedPayload convert(const lb_subagent_finished_payload_t* p) { + return { p->node_id, p->tool_use_id, p->status, p->started_at, p->elapsed_time, p->error, + convert(p->outputs) }; +} +inline agent::AgentToolStartedPayload convert(const lb_agent_tool_started_payload_t* p) { + std::vector tip_chips(p->tip_chips, p->tip_chips + p->num_tip_chips); + return { p->node_id, p->tool_use_id, p->agent_tool_name, p->title, p->started_at, p->tool_args, + p->tool_name, p->tips, std::move(tip_chips), p->is_thinking }; +} +inline agent::AgentToolProgressPayload convert(const lb_agent_tool_progress_payload_t* p) { + return { p->node_id, p->parent_tool_call_id, p->agent_tool_name, p->inner_tool_name, + p->inner_tool_args, p->status, p->duration_ms, p->started_at, p->is_thinking }; +} +inline agent::AgentToolFinishedPayload convert(const lb_agent_tool_finished_payload_t* p) { + std::vector tip_chips(p->tip_chips, p->tip_chips + p->num_tip_chips); + return { p->node_id, p->tool_use_id, p->agent_tool_name, p->status, p->started_at, + p->elapsed_time, p->error, p->tool_args, p->outputs_json, p->tool_type, p->tips, + std::move(tip_chips), p->is_thinking }; +} +inline agent::QueryMaskedPayload convert(const lb_query_masked_payload_t* p) { + return { p->raw_query, p->masked_query }; +} +inline agent::PlanChangedPayload convert(const lb_plan_changed_payload_t* p) { + return { p->node_id, p->started_at, p->outputs_json, p->tool_name }; +} +inline agent::ContextCompressStartedPayload convert(const lb_context_compress_started_payload_t* p) { + return { p->started_at, p->inputs_json }; +} +inline agent::ContextCompressFinishedPayload convert( + const lb_context_compress_finished_payload_t* p) { + return { p->created_at, p->inputs_json, p->outputs_json }; +} +inline agent::ChatFinishedPayload convert(const lb_chat_finished_payload_t* p) { + return { p->chat_id, p->chat_uid, p->message_id, p->error, p->error_message }; +} +inline agent::ChatTitleUpdatedPayload convert(const lb_chat_title_updated_payload_t* p) { + return { p->chat_id, p->chat_uid, p->source, p->title, p->updated_at }; +} +inline agent::ConversationStreamEvent convert(const lb_conversation_stream_event_t* e) { + agent::ConversationStreamEvent event{}; + switch (e->kind) { + case ChatStarted: + event.kind = agent::ConversationStreamEventKind::ChatStarted; + event.chat_started = convert(e->chat_started); + break; + case WorkflowStarted: + event.kind = agent::ConversationStreamEventKind::WorkflowStarted; + event.workflow_started = convert(e->workflow_started); + break; + case Message: + event.kind = agent::ConversationStreamEventKind::Message; + event.message = convert(e->message); + break; + case Ping: + event.kind = agent::ConversationStreamEventKind::Ping; + break; + case ThinkingStarted: + event.kind = agent::ConversationStreamEventKind::ThinkingStarted; + event.thinking_started = convert(e->thinking_started); + break; + case ThinkingFinished: + event.kind = agent::ConversationStreamEventKind::ThinkingFinished; + event.thinking_finished = convert(e->thinking_finished); + break; + case NodeToolUseStarted: + event.kind = agent::ConversationStreamEventKind::NodeToolUseStarted; + event.node_tool_use_started = convert(e->node_tool_use_started); + break; + case NodeToolUseFinished: + event.kind = agent::ConversationStreamEventKind::NodeToolUseFinished; + event.node_tool_use_finished = convert(e->node_tool_use_finished); + break; + case SubagentStarted: + event.kind = agent::ConversationStreamEventKind::SubagentStarted; + event.subagent_started = convert(e->subagent_started); + break; + case SubagentProgress: + event.kind = agent::ConversationStreamEventKind::SubagentProgress; + event.subagent_progress = convert(e->subagent_progress); + break; + case SubagentFinished: + event.kind = agent::ConversationStreamEventKind::SubagentFinished; + event.subagent_finished = convert(e->subagent_finished); + break; + case AgentToolStarted: + event.kind = agent::ConversationStreamEventKind::AgentToolStarted; + event.agent_tool_started = convert(e->agent_tool_started); + break; + case AgentToolProgress: + event.kind = agent::ConversationStreamEventKind::AgentToolProgress; + event.agent_tool_progress = convert(e->agent_tool_progress); + break; + case AgentToolFinished: + event.kind = agent::ConversationStreamEventKind::AgentToolFinished; + event.agent_tool_finished = convert(e->agent_tool_finished); + break; + case HumanInteractionRequired: + event.kind = agent::ConversationStreamEventKind::HumanInteractionRequired; + event.human_interaction_required = convert(e->human_interaction_required); + break; + case QueryMasked: + event.kind = agent::ConversationStreamEventKind::QueryMasked; + event.query_masked = convert(e->query_masked); + break; + case PlanChanged: + event.kind = agent::ConversationStreamEventKind::PlanChanged; + event.plan_changed = convert(e->plan_changed); + break; + case ContextCompressStarted: + event.kind = agent::ConversationStreamEventKind::ContextCompressStarted; + event.context_compress_started = convert(e->context_compress_started); + break; + case ContextCompressFinished: + event.kind = agent::ConversationStreamEventKind::ContextCompressFinished; + event.context_compress_finished = convert(e->context_compress_finished); + break; + case ChatFinished: + event.kind = agent::ConversationStreamEventKind::ChatFinished; + event.chat_finished = convert(e->chat_finished); + break; + case WorkflowFinished: + event.kind = agent::ConversationStreamEventKind::WorkflowFinished; + event.workflow_finished = convert(e->workflow_finished); + break; + case ChatTitleUpdated: + event.kind = agent::ConversationStreamEventKind::ChatTitleUpdated; + event.chat_title_updated = convert(e->chat_title_updated); + break; + case Other: + event.kind = agent::ConversationStreamEventKind::Other; + event.other_event = std::string(e->other_event); + event.other_json = std::string(e->other_json); + break; + default: + throw std::invalid_argument("unreachable"); + } + return event; +} + } // namespace convert } // namespace longbridge diff --git a/cpp/test/main.cpp b/cpp/test/main.cpp index 51f7015df6..c69b19336a 100644 --- a/cpp/test/main.cpp +++ b/cpp/test/main.cpp @@ -35,6 +35,17 @@ main(int argc, char const* argv[]) } }); + agent::AgentContext agent_ctx = agent::AgentContext::create(config); + + agent_ctx.workspaces([](auto res) { + if (!res) { + std::cout << "failed to list workspaces: " << *res.status().message() + << std::endl; + return; + } + std::cout << "workspaces: " << res->workspaces.size() << std::endl; + }); + std::cin.get(); return 0; } diff --git a/java/Makefile.toml b/java/Makefile.toml index 40aaf2645e..8388758cce 100644 --- a/java/Makefile.toml +++ b/java/Makefile.toml @@ -15,7 +15,7 @@ cwd = "java" [tasks.javah] args = [ "--release", - "8", + "9", "-h", "c", "-cp", diff --git a/java/javasrc/pom.xml b/java/javasrc/pom.xml index b84e537fac..eb4aae7fef 100644 --- a/java/javasrc/pom.xml +++ b/java/javasrc/pom.xml @@ -167,9 +167,15 @@ - 1.08 - 8 - 8 + + 9 + 9 + 9 utf-8 \ No newline at end of file diff --git a/java/javasrc/src/main/java/com/longbridge/SdkNative.java b/java/javasrc/src/main/java/com/longbridge/SdkNative.java index 41230ace36..eca0f168d5 100644 --- a/java/javasrc/src/main/java/com/longbridge/SdkNative.java +++ b/java/javasrc/src/main/java/com/longbridge/SdkNative.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.time.LocalDate; +import java.util.concurrent.Flow; import java.util.function.Consumer; import org.scijava.nativelib.NativeLoader; @@ -470,6 +471,29 @@ public static native void portfolioContextProfitAnalysisFlows(long context, Obje public static native void quoteContextUpdatePinned(long context, Object req, AsyncCallback callback); + // ── AgentContext ────────────────────────────────────────────── + + public static native long newAgentContext(long config); + public static native void freeAgentContext(long context); + + public static native void agentContextWorkspaces(long context, AsyncCallback callback); + public static native void agentContextAgents(long context, String workspaceId, Object opts, + AsyncCallback callback); + public static native void agentContextConversation(long context, String agentId, String query, + String chatUid, AsyncCallback callback); + public static native void agentContextContinueConversation(long context, String agentId, String chatUid, + String messageId, String answersByToolCallJson, AsyncCallback callback); + + public static native void agentContextConversationStreamSubscribe(long context, String agentId, String query, + String chatUid, Flow.Subscriber subscriber); + public static native void agentContextContinueConversationStreamSubscribe(long context, String agentId, + String chatUid, String messageId, String answersByToolCallJson, + Flow.Subscriber subscriber); + + public static native void conversationStreamSubscriptionRequest(long handle, long n); + public static native void conversationStreamSubscriptionCancel(long handle); + public static native void freeConversationStreamSubscription(long handle); + static { try { NativeLoader.loadLibrary("longbridge_java"); diff --git a/java/javasrc/src/main/java/com/longbridge/agent/Agent.java b/java/javasrc/src/main/java/com/longbridge/agent/Agent.java new file mode 100644 index 0000000000..6632cd23d2 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/Agent.java @@ -0,0 +1,107 @@ +package com.longbridge.agent; + +/** + * An Agent in a Workspace + */ +public class Agent { + private String uid; + private String name; + private String description; + private String mode; + private String icon; + private boolean isPublished; + private long publishedAt; + private long createdAt; + private long updatedAt; + + /** + * Returns the Agent UID, used as the path parameter of + * {@link AgentContext#conversation}. + * + * @return Agent UID + */ + public String getUid() { + return uid; + } + + /** + * Returns the Agent name. + * + * @return Agent name + */ + public String getName() { + return name; + } + + /** + * Returns the Agent description. + * + * @return Agent description + */ + public String getDescription() { + return description; + } + + /** + * Returns the Agent mode, e.g. {@code chat}. + * + * @return Agent mode + */ + public String getMode() { + return mode; + } + + /** + * Returns the icon URL. + * + * @return icon URL + */ + public String getIcon() { + return icon; + } + + /** + * Returns whether the Agent is published; only published Agents can start + * conversations. + * + * @return {@code true} if published + */ + public boolean isPublished() { + return isPublished; + } + + /** + * Returns the publish time, Unix timestamp in seconds; {@code 0} if + * unpublished. + * + * @return publish time + */ + public long getPublishedAt() { + return publishedAt; + } + + /** + * Returns the creation time, Unix timestamp in seconds. + * + * @return creation time + */ + public long getCreatedAt() { + return createdAt; + } + + /** + * Returns the last updated time, Unix timestamp in seconds. + * + * @return last updated time + */ + public long getUpdatedAt() { + return updatedAt; + } + + @Override + public String toString() { + return "Agent [uid=" + uid + ", name=" + name + ", description=" + description + ", mode=" + mode + + ", icon=" + icon + ", isPublished=" + isPublished + ", publishedAt=" + publishedAt + ", createdAt=" + + createdAt + ", updatedAt=" + updatedAt + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/AgentContext.java b/java/javasrc/src/main/java/com/longbridge/agent/AgentContext.java new file mode 100644 index 0000000000..ebed1eb72e --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/AgentContext.java @@ -0,0 +1,204 @@ +package com.longbridge.agent; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; + +import com.google.gson.Gson; +import com.longbridge.AsyncCallback; +import com.longbridge.Config; +import com.longbridge.OpenApiException; +import com.longbridge.SdkNative; + +/** + * AI Agent conversation context. + *

+ * Reference: + * https://open.longbridge.com/en/docs/ai/chat/conversation + */ +public class AgentContext implements AutoCloseable { + private long raw; + + /** + * Create an AgentContext object + * + * @param config Config object + * @return A AgentContext object + */ + public static AgentContext create(Config config) { + AgentContext ctx = new AgentContext(); + ctx.raw = SdkNative.newAgentContext(config.getRaw()); + return ctx; + } + + @Override + public void close() throws Exception { + SdkNative.freeAgentContext(raw); + } + + /** + * List the Workspaces the current account belongs to. + * + * @return A Future representing the result of the operation + * @throws OpenApiException If an error occurs + */ + public CompletableFuture workspaces() throws OpenApiException { + return AsyncCallback.executeTask((callback) -> { + SdkNative.agentContextWorkspaces(this.raw, callback); + }); + } + + /** + * List the Agents in the specified Workspace. + * + * @param workspaceId Workspace ID + * @param opts Options for this request, may be {@code null} + * @return A Future representing the result of the operation + * @throws OpenApiException If an error occurs + */ + public CompletableFuture agents(String workspaceId, GetAgentsOptions opts) + throws OpenApiException { + return AsyncCallback.executeTask((callback) -> { + SdkNative.agentContextAgents(this.raw, workspaceId, opts, callback); + }); + } + + /** + * Start a conversation with the specified Agent, blocking until the run + * succeeds, is interrupted, or fails. + * + *

+     * {@code
+     * import com.longbridge.*;
+     * import com.longbridge.agent.*;
+     *
+     * class Main {
+     *     public static void main(String[] args) throws Exception {
+     *         OAuth oauth = new OAuthBuilder("your-client-id")
+     *             .build(url -> System.out.println("Visit: " + url)).get();
+     *         try (Config config = Config.fromOAuth(oauth); AgentContext ctx = AgentContext.create(config)) {
+     *             WorkspacesResponse workspaces = ctx.workspaces().get();
+     *             AgentsResponse agents = ctx.agents(workspaces.getWorkspaces()[0].getId(), null).get();
+     *             ConversationResponse resp = ctx.conversation(agents.getAgents()[0].getUid(),
+     *                     "How has Tesla stock performed recently?", null).get();
+     *             System.out.println(resp);
+     *         }
+     *     }
+     * }
+     * }
+     * 
+ * + * @param agentId Agent UID + * @param query User query + * @param chatUid Conversation identifier to continue an existing chat, or + * {@code null} to start a new one + * @return A Future representing the result of the operation + * @throws OpenApiException If an error occurs + */ + public CompletableFuture conversation(String agentId, String query, String chatUid) + throws OpenApiException { + return AsyncCallback.executeTask((callback) -> { + SdkNative.agentContextConversation(this.raw, agentId, query, chatUid, callback); + }); + } + + /** + * Resume an interrupted conversation, blocking until the run succeeds, is + * interrupted again, or fails. + * + * @param agentId Agent UID + * @param chatUid Conversation identifier + * @param messageId ID of the paused message (see + * {@link Interrupt#getMessageId}) + * @param answersByToolCall Answers keyed by {@code toolCallId}, each value + * being a map of question text to answer; may be + * {@code null} if there is nothing to answer + * @return A Future representing the result of the operation + * @throws OpenApiException If an error occurs + */ + public CompletableFuture continueConversation(String agentId, String chatUid, + String messageId, Map> answersByToolCall) throws OpenApiException { + String answersJson = toAnswersJson(answersByToolCall); + return AsyncCallback.executeTask((callback) -> { + SdkNative.agentContextContinueConversation(this.raw, agentId, chatUid, messageId, answersJson, callback); + }); + } + + /** + * Start a conversation with the specified Agent, returning a + * {@link Flow.Publisher} of run-progress events over SSE. The run's + * outcome is carried by a {@link WorkflowFinishedEvent} (succeeded, + * failed, or stopped) or, if the Agent needs more input from you, a + * {@link HumanInteractionRequiredEvent} instead (unless the stream itself + * errors first, delivered via {@code Flow.Subscriber#onError}) — an + * interrupted run never emits a {@link WorkflowFinishedEvent}. Neither is + * necessarily the last event delivered — the server may still emit a few + * more housekeeping events (e.g. a {@link ChatTitleUpdatedEvent}) before + * actually closing the connection, so keep consuming until + * {@code onComplete} rather than stopping as soon as you see one. + *

+ * This method itself performs no I/O — it returns a cold + * {@link Flow.Publisher} immediately; the HTTP/SSE connection is only + * established once a subscriber calls {@code subscribe}, matching + * Reactive Streams' lazy-publisher convention. The returned publisher + * carries real backpressure: no more events are pulled off the stream + * than have been requested via {@link Flow.Subscription#request}. + * + *

+     * {@code
+     * Flow.Publisher publisher =
+     *     ctx.conversationStream(agentId, "How has Tesla stock performed recently?", null);
+     * publisher.subscribe(new Flow.Subscriber() {
+     *     public void onSubscribe(Flow.Subscription subscription) {
+     *         subscription.request(Long.MAX_VALUE); // unbounded demand
+     *     }
+     *     public void onNext(ConversationStreamEvent event) {
+     *         System.out.println(event);
+     *     }
+     *     public void onError(Throwable err) {
+     *         System.out.println("failed: " + err.getMessage());
+     *     }
+     *     public void onComplete() {
+     *         System.out.println("done");
+     *     }
+     * });
+     * }
+     * 
+ * + * @param agentId Agent UID + * @param query User query + * @param chatUid Conversation identifier to continue an existing chat, or + * {@code null} to start a new one + * @return A cold {@link Flow.Publisher} of conversation stream events + */ + public Flow.Publisher conversationStream(String agentId, String query, String chatUid) { + return new ConversationStreamPublisher(this.raw, agentId, query, chatUid); + } + + /** + * Resume an interrupted conversation, returning a {@link Flow.Publisher} + * of run-progress events over SSE. See {@link #conversationStream} for + * the lazy-publisher/backpressure semantics. + * + * @param agentId Agent UID + * @param chatUid Conversation identifier + * @param messageId ID of the paused message (see + * {@link Interrupt#getMessageId}) + * @param answersByToolCall Answers keyed by {@code toolCallId}, each value + * being a map of question text to answer; may be + * {@code null} if there is nothing to answer + * @return A cold {@link Flow.Publisher} of conversation stream events + */ + public Flow.Publisher continueConversationStream(String agentId, String chatUid, + String messageId, Map> answersByToolCall) { + String answersJson = toAnswersJson(answersByToolCall); + return new ConversationStreamPublisher(this.raw, agentId, chatUid, messageId, answersJson); + } + + private static String toAnswersJson(Map> answersByToolCall) { + Map> answers = answersByToolCall != null ? answersByToolCall + : Collections.emptyMap(); + return new Gson().toJson(answers); + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/AgentToolFinishedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/AgentToolFinishedEvent.java new file mode 100644 index 0000000000..7b7645da4e --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/AgentToolFinishedEvent.java @@ -0,0 +1,148 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * The delegated Agent's run has finished. + */ +public final class AgentToolFinishedEvent extends ConversationStreamEvent { + private String nodeId; + private String toolUseId; + private String agentToolName; + private String status; + private long startedAt; + private double elapsedTime; + private String error; + private String toolArgs; + private String outputs; + private String toolType; + private String tips; + private String[] tipChips; + private boolean isThinking; + + /** + * Returns the ID of the calling node. + * + * @return ID of the calling node + */ + public String getNodeId() { + return nodeId; + } + + /** + * Returns the ID matching the {@code toolUseId} of + * {@link AgentToolStartedEvent}. + * + * @return matching tool use ID + */ + public String getToolUseId() { + return toolUseId; + } + + /** + * Returns the identifier of the Agent being called. + * + * @return identifier of the Agent being called + */ + public String getAgentToolName() { + return agentToolName; + } + + /** + * Returns the status: {@code succeeded} / {@code failed}. + * + * @return status + */ + public String getStatus() { + return status; + } + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns the total duration in seconds. + * + * @return total duration in seconds + */ + public double getElapsedTime() { + return elapsedTime; + } + + /** + * Returns the error description on failure. + * + * @return error description + */ + public String getError() { + return error; + } + + /** + * Returns the call arguments as a JSON string. + * + * @return call arguments (JSON string) + */ + public String getToolArgs() { + return toolArgs; + } + + /** + * Returns the result of the delegated Agent, as JSON text. + * + * @return result of the delegated Agent (JSON text), or {@code null} + */ + public String getOutputs() { + return outputs; + } + + /** + * Returns the tool category. + * + * @return tool category + */ + public String getToolType() { + return toolType; + } + + /** + * Returns the progress text; may be empty. + * + * @return progress text + */ + public String getTips() { + return tips; + } + + /** + * Returns the short tags; may be empty. + * + * @return short tags + */ + public String[] getTipChips() { + return tipChips; + } + + /** + * Returns whether the call happened during the thinking phase. + * + * @return {@code true} if during the thinking phase + */ + public boolean isThinking() { + return isThinking; + } + + @Override + public String toString() { + return "AgentToolFinishedEvent [nodeId=" + nodeId + ", toolUseId=" + toolUseId + ", agentToolName=" + + agentToolName + ", status=" + status + ", startedAt=" + startedAt + ", elapsedTime=" + elapsedTime + + ", error=" + error + ", toolArgs=" + toolArgs + ", outputs=" + outputs + ", toolType=" + toolType + + ", tips=" + tips + ", tipChips=" + Arrays.toString(tipChips) + ", isThinking=" + isThinking + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/AgentToolProgressEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/AgentToolProgressEvent.java new file mode 100644 index 0000000000..fd1d1505cd --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/AgentToolProgressEvent.java @@ -0,0 +1,107 @@ +package com.longbridge.agent; + +/** + * Emitted for each inner tool call the delegated Agent makes. + */ +public final class AgentToolProgressEvent extends ConversationStreamEvent { + private String nodeId; + private String parentToolCallId; + private String agentToolName; + private String innerToolName; + private String innerToolArgs; + private String status; + private long durationMs; + private long startedAt; + private boolean isThinking; + + /** + * Returns the ID of the calling node. + * + * @return ID of the calling node + */ + public String getNodeId() { + return nodeId; + } + + /** + * Returns the {@code toolUseId} of the owning + * {@link AgentToolStartedEvent}. + * + * @return {@code toolUseId} of the owning {@link AgentToolStartedEvent} + */ + public String getParentToolCallId() { + return parentToolCallId; + } + + /** + * Returns the identifier of the Agent being called. + * + * @return identifier of the Agent being called + */ + public String getAgentToolName() { + return agentToolName; + } + + /** + * Returns the name of the inner tool the delegated Agent called. + * + * @return name of the inner tool the delegated Agent called + */ + public String getInnerToolName() { + return innerToolName; + } + + /** + * Returns the arguments of that inner call, as a JSON string. + * + * @return arguments of that inner call (JSON string) + */ + public String getInnerToolArgs() { + return innerToolArgs; + } + + /** + * Returns the status of the inner call: {@code running} / + * {@code succeeded} / {@code failed}. + * + * @return status of the inner call + */ + public String getStatus() { + return status; + } + + /** + * Returns the duration of the inner call in milliseconds. + * + * @return duration of the inner call in milliseconds + */ + public long getDurationMs() { + return durationMs; + } + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns whether the call happened during the thinking phase. + * + * @return {@code true} if during the thinking phase + */ + public boolean isThinking() { + return isThinking; + } + + @Override + public String toString() { + return "AgentToolProgressEvent [nodeId=" + nodeId + ", parentToolCallId=" + parentToolCallId + + ", agentToolName=" + agentToolName + ", innerToolName=" + innerToolName + ", innerToolArgs=" + + innerToolArgs + ", status=" + status + ", durationMs=" + durationMs + ", startedAt=" + startedAt + + ", isThinking=" + isThinking + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/AgentToolStartedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/AgentToolStartedEvent.java new file mode 100644 index 0000000000..d3d96bfd2b --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/AgentToolStartedEvent.java @@ -0,0 +1,119 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * The Agent has delegated to another Agent as a tool. When the Agent + * delegates to another Agent as a tool, that inner run is reported with the + * {@code agent_tool_*} family — the shape mirrors the subagent events. + */ +public final class AgentToolStartedEvent extends ConversationStreamEvent { + private String nodeId; + private String toolUseId; + private String agentToolName; + private String title; + private long startedAt; + private String toolArgs; + private String toolName; + private String tips; + private String[] tipChips; + private boolean isThinking; + + /** + * Returns the ID of the calling node. + * + * @return ID of the calling node + */ + public String getNodeId() { + return nodeId; + } + + /** + * Returns the unique ID of this call; matches the finished event. + * + * @return unique ID of this call + */ + public String getToolUseId() { + return toolUseId; + } + + /** + * Returns the identifier of the Agent being called. + * + * @return identifier of the Agent being called + */ + public String getAgentToolName() { + return agentToolName; + } + + /** + * Returns the display title; may be empty. + * + * @return display title + */ + public String getTitle() { + return title; + } + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns the call arguments as a JSON string. + * + * @return call arguments (JSON string) + */ + public String getToolArgs() { + return toolArgs; + } + + /** + * Returns the localized display name. + * + * @return localized display name + */ + public String getToolName() { + return toolName; + } + + /** + * Returns the progress text; may be empty. + * + * @return progress text + */ + public String getTips() { + return tips; + } + + /** + * Returns the short tags; may be empty. + * + * @return short tags + */ + public String[] getTipChips() { + return tipChips; + } + + /** + * Returns whether the call happened during the thinking phase. + * + * @return {@code true} if called during the thinking phase + */ + public boolean isThinking() { + return isThinking; + } + + @Override + public String toString() { + return "AgentToolStartedEvent [nodeId=" + nodeId + ", toolUseId=" + toolUseId + ", agentToolName=" + + agentToolName + ", title=" + title + ", startedAt=" + startedAt + ", toolArgs=" + toolArgs + + ", toolName=" + toolName + ", tips=" + tips + ", tipChips=" + Arrays.toString(tipChips) + + ", isThinking=" + isThinking + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/AgentsResponse.java b/java/javasrc/src/main/java/com/longbridge/agent/AgentsResponse.java new file mode 100644 index 0000000000..1ec2c6fddd --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/AgentsResponse.java @@ -0,0 +1,34 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * Response for {@link AgentContext#agents} + */ +public class AgentsResponse { + private Agent[] agents; + private int total; + + /** + * Returns the Agent list. + * + * @return Agent list + */ + public Agent[] getAgents() { + return agents; + } + + /** + * Returns the total number of matching Agents. + * + * @return total number of matching Agents + */ + public int getTotal() { + return total; + } + + @Override + public String toString() { + return "AgentsResponse [agents=" + Arrays.toString(agents) + ", total=" + total + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ChatFinishedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/ChatFinishedEvent.java new file mode 100644 index 0000000000..0220c4664e --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ChatFinishedEvent.java @@ -0,0 +1,64 @@ +package com.longbridge.agent; + +/** + * Observed once all {@link MessageEvent}s for this round have been sent, + * shortly before a {@link WorkflowFinishedEvent}. + */ +public final class ChatFinishedEvent extends ConversationStreamEvent { + private long chatId; + private String chatUid; + private String messageId; + private String error; + private String errorMessage; + + /** + * Returns the ID of the owning conversation. + * + * @return conversation ID + */ + public long getChatId() { + return chatId; + } + + /** + * Returns the conversation identifier. + * + * @return conversation identifier + */ + public String getChatUid() { + return chatUid; + } + + /** + * Returns the message ID of this round. + * + * @return message ID + */ + public String getMessageId() { + return messageId; + } + + /** + * Returns the error code; empty string in every run observed so far. + * + * @return error code + */ + public String getError() { + return error; + } + + /** + * Returns the error message; empty string in every run observed so far. + * + * @return error message + */ + public String getErrorMessage() { + return errorMessage; + } + + @Override + public String toString() { + return "ChatFinishedEvent [chatId=" + chatId + ", chatUid=" + chatUid + ", messageId=" + messageId + + ", error=" + error + ", errorMessage=" + errorMessage + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ChatStartedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/ChatStartedEvent.java new file mode 100644 index 0000000000..896d4d06df --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ChatStartedEvent.java @@ -0,0 +1,32 @@ +package com.longbridge.agent; + +/** + * The run has started. Always the first event of a stream. + */ +public final class ChatStartedEvent extends ConversationStreamEvent { + private String chatUid; + private String messageId; + + /** + * Returns the conversation identifier. + * + * @return conversation identifier + */ + public String getChatUid() { + return chatUid; + } + + /** + * Returns the message ID of this round. + * + * @return message ID + */ + public String getMessageId() { + return messageId; + } + + @Override + public String toString() { + return "ChatStartedEvent [chatUid=" + chatUid + ", messageId=" + messageId + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ChatTitleUpdatedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/ChatTitleUpdatedEvent.java new file mode 100644 index 0000000000..c80489bba7 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ChatTitleUpdatedEvent.java @@ -0,0 +1,65 @@ +package com.longbridge.agent; + +/** + * The server auto-generating a short title for the conversation as a UI + * convenience. Can arrive before or after a + * {@link WorkflowFinishedEvent}; not tied to the run's outcome. + */ +public final class ChatTitleUpdatedEvent extends ConversationStreamEvent { + private long chatId; + private String chatUid; + private String source; + private String title; + private long updatedAt; + + /** + * Returns the ID of the owning conversation. + * + * @return conversation ID + */ + public long getChatId() { + return chatId; + } + + /** + * Returns the conversation identifier. + * + * @return conversation identifier + */ + public String getChatUid() { + return chatUid; + } + + /** + * Returns where the title came from, e.g. {@code "ai_generated"}. + * + * @return where the title came from + */ + public String getSource() { + return source; + } + + /** + * Returns the new (possibly truncated) title. + * + * @return the new (possibly truncated) title + */ + public String getTitle() { + return title; + } + + /** + * Returns the Unix timestamp (in seconds) at which the title was updated. + * + * @return Unix timestamp (in seconds) at which the title was updated + */ + public long getUpdatedAt() { + return updatedAt; + } + + @Override + public String toString() { + return "ChatTitleUpdatedEvent [chatId=" + chatId + ", chatUid=" + chatUid + ", source=" + source + ", title=" + + title + ", updatedAt=" + updatedAt + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ContextCompressFinishedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/ContextCompressFinishedEvent.java new file mode 100644 index 0000000000..2bcde3e61c --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ContextCompressFinishedEvent.java @@ -0,0 +1,44 @@ +package com.longbridge.agent; + +/** + * The context-compression pass has finished. + */ +public final class ContextCompressFinishedEvent extends ConversationStreamEvent { + private String createdAt; + private String inputs; + private String outputs; + + /** + * Returns the finish time, as an RFC 3339 timestamp. Unlike other events, + * the timestamp here is a string rather than a Unix timestamp. + * + * @return finish time (RFC 3339) + */ + public String getCreatedAt() { + return createdAt; + } + + /** + * Returns the compression input summary, as JSON text. + * + * @return compression input summary (JSON text), or {@code null} + */ + public String getInputs() { + return inputs; + } + + /** + * Returns the compression result summary, as JSON text. + * + * @return compression result summary (JSON text), or {@code null} + */ + public String getOutputs() { + return outputs; + } + + @Override + public String toString() { + return "ContextCompressFinishedEvent [createdAt=" + createdAt + ", inputs=" + inputs + ", outputs=" + outputs + + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ContextCompressStartedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/ContextCompressStartedEvent.java new file mode 100644 index 0000000000..db9471b468 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ContextCompressStartedEvent.java @@ -0,0 +1,34 @@ +package com.longbridge.agent; + +/** + * A context-compression pass has started, marking the start of a + * context-compression pass triggered by a long conversation. + */ +public final class ContextCompressStartedEvent extends ConversationStreamEvent { + private String startedAt; + private String inputs; + + /** + * Returns the start time, as an RFC 3339 timestamp. Unlike other events, + * the timestamp here is a string rather than a Unix timestamp. + * + * @return start time (RFC 3339) + */ + public String getStartedAt() { + return startedAt; + } + + /** + * Returns the compression input summary, as JSON text. + * + * @return compression input summary (JSON text), or {@code null} + */ + public String getInputs() { + return inputs; + } + + @Override + public String toString() { + return "ContextCompressStartedEvent [startedAt=" + startedAt + ", inputs=" + inputs + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ConversationError.java b/java/javasrc/src/main/java/com/longbridge/agent/ConversationError.java new file mode 100644 index 0000000000..ea64074e34 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ConversationError.java @@ -0,0 +1,38 @@ +package com.longbridge.agent; + +/** + * Present when a conversation run failed. + *

+ * This describes a failure of the conversation run itself (the HTTP + * call succeeded, but the Agent's workflow ended in the {@code failed} + * status) — it is unrelated to {@link com.longbridge.OpenApiException}, which + * is thrown for request-level failures (network, auth, malformed request, + * etc). + */ +public class ConversationError { + private int code; + private String message; + + /** + * Returns the error code. + * + * @return error code + */ + public int getCode() { + return code; + } + + /** + * Returns the error message. + * + * @return error message + */ + public String getMessage() { + return message; + } + + @Override + public String toString() { + return "ConversationError [code=" + code + ", message=" + message + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ConversationResponse.java b/java/javasrc/src/main/java/com/longbridge/agent/ConversationResponse.java new file mode 100644 index 0000000000..d7c6f23356 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ConversationResponse.java @@ -0,0 +1,101 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * Response for {@link AgentContext#conversation}, + * {@link AgentContext#continueConversation}, and the final result of the + * streamed counterparts (delivered as {@link WorkflowFinishedEvent#getResponse}). + */ +public class ConversationResponse { + private String chatUid; + private String messageId; + private ConversationStatus status; + private String answer; + private Reference[] references; + private double elapsedTime; + private Interrupt interrupt; + private ConversationError error; + + /** + * Returns the conversation identifier, used for follow-up questions and + * troubleshooting. + * + * @return conversation identifier + */ + public String getChatUid() { + return chatUid; + } + + /** + * Returns the message ID of this round. + * + * @return message ID + */ + public String getMessageId() { + return messageId; + } + + /** + * Returns the final run status. + * + * @return final run status + */ + public ConversationStatus getStatus() { + return status; + } + + /** + * Returns the final answer text; valid when {@link #getStatus} is + * {@link ConversationStatus#Succeeded}. + * + * @return final answer text + */ + public String getAnswer() { + return answer; + } + + /** + * Returns the sources referenced by the answer. + * + * @return referenced sources + */ + public Reference[] getReferences() { + return references; + } + + /** + * Returns the run duration in seconds. + * + * @return run duration in seconds + */ + public double getElapsedTime() { + return elapsedTime; + } + + /** + * Returns the interrupt details; present only when {@link #getStatus} is + * {@link ConversationStatus#Interrupted}. + * + * @return interrupt details, or {@code null} + */ + public Interrupt getInterrupt() { + return interrupt; + } + + /** + * Returns the error details; present only when the run failed. + * + * @return error details, or {@code null} + */ + public ConversationError getError() { + return error; + } + + @Override + public String toString() { + return "ConversationResponse [chatUid=" + chatUid + ", messageId=" + messageId + ", status=" + status + + ", answer=" + answer + ", references=" + Arrays.toString(references) + ", elapsedTime=" + + elapsedTime + ", interrupt=" + interrupt + ", error=" + error + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ConversationStatus.java b/java/javasrc/src/main/java/com/longbridge/agent/ConversationStatus.java new file mode 100644 index 0000000000..e40bd4bcb0 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ConversationStatus.java @@ -0,0 +1,15 @@ +package com.longbridge.agent; + +/** + * Final run status of a conversation + */ +public enum ConversationStatus { + /** The run completed successfully */ + Succeeded, + /** The run is paused, waiting for {@link AgentContext#continueConversation} */ + Interrupted, + /** The run failed */ + Failed, + /** The run was stopped */ + Stopped, +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamEvent.java new file mode 100644 index 0000000000..be564bca1a --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamEvent.java @@ -0,0 +1,52 @@ +package com.longbridge.agent; + +/** + * One event observed while streaming {@link AgentContext#conversationStream} + * or {@link AgentContext#continueConversationStream}. + *

+ * This is a sealed-style class hierarchy mirroring the Rust + * {@code ConversationStreamEvent} enum: each concrete subclass below carries + * exactly one variant's payload. Use {@code instanceof} (or a pattern-matching + * {@code switch} on Java 21+) to dispatch on the concrete type: + * + *

{@code
+ * publisher.subscribe(new Flow.Subscriber() {
+ *     public void onNext(ConversationStreamEvent event) {
+ *         if (event instanceof MessageEvent message) {
+ *             System.out.print(message.getText());
+ *         } else if (event instanceof WorkflowFinishedEvent finished) {
+ *             System.out.println(finished.getResponse());
+ *         }
+ *     }
+ *     // ...
+ * });
+ * }
+ * + * @see ChatStartedEvent + * @see WorkflowStartedEvent + * @see MessageEvent + * @see PingEvent + * @see ThinkingStartedEvent + * @see ThinkingFinishedEvent + * @see NodeToolUseStartedEvent + * @see NodeToolUseFinishedEvent + * @see SubagentStartedEvent + * @see SubagentProgressEvent + * @see SubagentFinishedEvent + * @see AgentToolStartedEvent + * @see AgentToolProgressEvent + * @see AgentToolFinishedEvent + * @see HumanInteractionRequiredEvent + * @see QueryMaskedEvent + * @see PlanChangedEvent + * @see ContextCompressStartedEvent + * @see ContextCompressFinishedEvent + * @see ChatFinishedEvent + * @see WorkflowFinishedEvent + * @see ChatTitleUpdatedEvent + * @see OtherEvent + */ +public abstract class ConversationStreamEvent { + ConversationStreamEvent() { + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamPublisher.java b/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamPublisher.java new file mode 100644 index 0000000000..fba2e60399 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamPublisher.java @@ -0,0 +1,63 @@ +package com.longbridge.agent; + +import java.util.concurrent.Flow; + +import com.longbridge.SdkNative; + +/** + * A cold {@link Flow.Publisher} of {@link ConversationStreamEvent}s. + *

+ * Constructing this object (via {@link AgentContext#conversationStream} / + * {@link AgentContext#continueConversationStream}) does not perform any I/O — + * per Reactive Streams convention, the underlying HTTP/SSE connection is only + * established once {@link #subscribe} is called, and a fresh, independent + * connection is started for every subscriber. + */ +public class ConversationStreamPublisher implements Flow.Publisher { + private final long ctx; + private final String agentId; + private final String query; + private final String chatUid; + private final String messageId; + private final String answersByToolCallJson; + + /** + * @hidden Constructs the "new conversation" variant, backing + * {@link AgentContext#conversationStream}. + */ + ConversationStreamPublisher(long ctx, String agentId, String query, String chatUid) { + this.ctx = ctx; + this.agentId = agentId; + this.query = query; + this.chatUid = chatUid; + this.messageId = null; + this.answersByToolCallJson = null; + } + + /** + * @hidden Constructs the "continue conversation" variant, backing + * {@link AgentContext#continueConversationStream}. + */ + ConversationStreamPublisher(long ctx, String agentId, String chatUid, String messageId, + String answersByToolCallJson) { + this.ctx = ctx; + this.agentId = agentId; + this.query = null; + this.chatUid = chatUid; + this.messageId = messageId; + this.answersByToolCallJson = answersByToolCallJson; + } + + @Override + public void subscribe(Flow.Subscriber subscriber) { + if (subscriber == null) { + throw new NullPointerException("subscriber"); + } + if (messageId == null) { + SdkNative.agentContextConversationStreamSubscribe(ctx, agentId, query, chatUid, subscriber); + } else { + SdkNative.agentContextContinueConversationStreamSubscribe(ctx, agentId, chatUid, messageId, + answersByToolCallJson, subscriber); + } + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamSubscription.java b/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamSubscription.java new file mode 100644 index 0000000000..7498b1e7a7 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ConversationStreamSubscription.java @@ -0,0 +1,45 @@ +package com.longbridge.agent; + +import java.lang.ref.Cleaner; +import java.util.concurrent.Flow; + +import com.longbridge.SdkNative; + +/** + * A {@link Flow.Subscription} for a conversation event stream. + *

+ * This wraps a native handle backed by real demand/credit bookkeeping: no + * more events are pulled off the underlying SSE stream than have been + * {@link #request(long) requested}. Call {@link #request(long)} from + * {@link Flow.Subscriber#onSubscribe} (and again whenever more events are + * wanted), and {@link #cancel()} to stop the stream early. + */ +public class ConversationStreamSubscription implements Flow.Subscription { + private static final Cleaner CLEANER = Cleaner.create(); + + private final long raw; + private final Cleaner.Cleanable cleanable; + + /** + * @hidden + */ + ConversationStreamSubscription(long raw) { + this.raw = raw; + // The cleaning action must not capture `this` (directly or via an + // instance field reference), only the primitive `raw` handle — this + // is the standard java.lang.ref.Cleaner idiom, since capturing `this` + // would keep the object reachable forever and the action would never + // run. + this.cleanable = CLEANER.register(this, () -> SdkNative.freeConversationStreamSubscription(raw)); + } + + @Override + public void request(long n) { + SdkNative.conversationStreamSubscriptionRequest(raw, n); + } + + @Override + public void cancel() { + SdkNative.conversationStreamSubscriptionCancel(raw); + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/GetAgentsOptions.java b/java/javasrc/src/main/java/com/longbridge/agent/GetAgentsOptions.java new file mode 100644 index 0000000000..fda927cec7 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/GetAgentsOptions.java @@ -0,0 +1,44 @@ +package com.longbridge.agent; + +/** + * Options for {@link AgentContext#agents} + */ +@SuppressWarnings("unused") +public class GetAgentsOptions { + private Integer page; + private Integer limit; + private String name; + + /** + * Sets the page number, starts at 1. + * + * @param page page number + * @return this instance for chaining + */ + public GetAgentsOptions setPage(Integer page) { + this.page = page; + return this; + } + + /** + * Sets the page size. + * + * @param limit page size + * @return this instance for chaining + */ + public GetAgentsOptions setLimit(Integer limit) { + this.limit = limit; + return this; + } + + /** + * Fuzzy search by Agent name. + * + * @param name Agent name + * @return this instance for chaining + */ + public GetAgentsOptions setName(String name) { + this.name = name; + return this; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/HumanInteractionRequiredEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/HumanInteractionRequiredEvent.java new file mode 100644 index 0000000000..26c3cef462 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/HumanInteractionRequiredEvent.java @@ -0,0 +1,35 @@ +package com.longbridge.agent; + +/** + * The run is paused: the Agent needs more information or confirmation from + * you, carrying the interrupt to resume from via + * {@link AgentContext#continueConversation}/ + * {@link AgentContext#continueConversationStream}. Unlike + * {@link WorkflowFinishedEvent}, this is emitted instead of (never alongside) + * a {@link WorkflowFinishedEvent} for the same run — an interrupted run never + * emits {@code workflow_finished} at all, so this is the terminal/final-result + * event for that outcome instead. + */ +public final class HumanInteractionRequiredEvent extends ConversationStreamEvent { + private ConversationResponse response; + + /** + * Returns the final conversation response, equivalent to what a blocking + * call to {@link AgentContext#conversation} or + * {@link AgentContext#continueConversation} would have returned. Its + * {@link ConversationResponse#getStatus} is + * {@link ConversationStatus#Interrupted}, and + * {@link ConversationResponse#getInterrupt} carries the questions to + * answer. + * + * @return final conversation response + */ + public ConversationResponse getResponse() { + return response; + } + + @Override + public String toString() { + return "HumanInteractionRequiredEvent [response=" + response + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/Interrupt.java b/java/javasrc/src/main/java/com/longbridge/agent/Interrupt.java new file mode 100644 index 0000000000..87c07d1aa6 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/Interrupt.java @@ -0,0 +1,67 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * Present when a conversation run is interrupted, waiting for + * {@link AgentContext#continueConversation} + */ +public class Interrupt { + private String nodeId; + private String toolCallId; + private Question[] questions; + private long messageId; + private long chatId; + + /** + * Returns the ID of the node that triggered the interrupt. + * + * @return node ID + */ + public String getNodeId() { + return nodeId; + } + + /** + * Returns the tool call ID of this inquiry; used as the answer key when + * continuing. + * + * @return tool call ID + */ + public String getToolCallId() { + return toolCallId; + } + + /** + * Returns the questions you need to answer. + * + * @return questions + */ + public Question[] getQuestions() { + return questions; + } + + /** + * Returns the ID of the paused message. + * + * @return message ID + */ + public long getMessageId() { + return messageId; + } + + /** + * Returns the ID of the owning conversation. + * + * @return conversation ID + */ + public long getChatId() { + return chatId; + } + + @Override + public String toString() { + return "Interrupt [nodeId=" + nodeId + ", toolCallId=" + toolCallId + ", questions=" + + Arrays.toString(questions) + ", messageId=" + messageId + ", chatId=" + chatId + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/MessageEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/MessageEvent.java new file mode 100644 index 0000000000..0f6fc06e35 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/MessageEvent.java @@ -0,0 +1,104 @@ +package com.longbridge.agent; + +/** + * An incremental piece of the answer. This is the highest-frequency event; + * concatenate {@link #getText} fragments in arrival order. + */ +public final class MessageEvent extends ConversationStreamEvent { + private String text; + private String messageType; + private String key; + private long startedAt; + private String stage; + private String stageTitle; + private String stageFinishedTitle; + private String outputs; + + /** + * Returns the incremental text fragment. + * + * @return incremental text fragment + */ + public String getText() { + return text; + } + + /** + * Returns the fragment kind: {@code answer} — final answer text; + * {@code think} — reasoning process; {@code process} — stage progress + * description. + * + * @return fragment kind + */ + public String getMessageType() { + return messageType; + } + + /** + * Returns the identifier of the stream segment this fragment belongs to. + * Fragments with the same key form one continuous block — group by key + * when rendering. + * + * @return identifier of the stream segment this fragment belongs to + */ + public String getKey() { + return key; + } + + /** + * Returns the time this segment started, Unix timestamp in seconds. + * + * @return time this segment started + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns the stage identifier; only present when {@link #getMessageType} + * is {@code "process"}. + * + * @return stage identifier + */ + public String getStage() { + return stage; + } + + /** + * Returns the stage title while running; only present when + * {@link #getMessageType} is {@code "process"}. + * + * @return stage title while running + */ + public String getStageTitle() { + return stageTitle; + } + + /** + * Returns the stage title after it finishes; only present when + * {@link #getMessageType} is {@code "process"}. + * + * @return stage title after it finishes + */ + public String getStageFinishedTitle() { + return stageFinishedTitle; + } + + /** + * Returns the extra payload attached to the fragment, as JSON text; + * usually absent. + * + * @return extra payload attached to the fragment (JSON text), or + * {@code null} + */ + public String getOutputs() { + return outputs; + } + + @Override + public String toString() { + return "MessageEvent [text=" + text + ", messageType=" + messageType + ", key=" + key + ", startedAt=" + + startedAt + ", stage=" + stage + ", stageTitle=" + stageTitle + ", stageFinishedTitle=" + + stageFinishedTitle + ", outputs=" + outputs + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseFinishedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseFinishedEvent.java new file mode 100644 index 0000000000..d61959abcb --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseFinishedEvent.java @@ -0,0 +1,158 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * The tool call has ended. + */ +public final class NodeToolUseFinishedEvent extends ConversationStreamEvent { + private String toolUseId; + private String status; + private String error; + private double elapsedTime; + private long startedAt; + private String toolName; + private String toolFuncName; + private String toolArgs; + private String toolType; + private String tips; + private String[] tipChips; + private int iteration; + private boolean isThinking; + private NodeToolUseOutputs outputs; + + /** + * Returns the ID matching the {@code toolUseId} of the started event. + * + * @return matching tool use ID + */ + public String getToolUseId() { + return toolUseId; + } + + /** + * Returns the call status: {@code succeeded} / {@code failed}. + * + * @return call status + */ + public String getStatus() { + return status; + } + + /** + * Returns the error description on failure. + * + * @return error description + */ + public String getError() { + return error; + } + + /** + * Returns the call duration in seconds. + * + * @return call duration in seconds + */ + public double getElapsedTime() { + return elapsedTime; + } + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns the localized display name. + * + * @return localized display name + */ + public String getToolName() { + return toolName; + } + + /** + * Returns the locale-stable tool identifier. + * + * @return locale-stable tool identifier + */ + public String getToolFuncName() { + return toolFuncName; + } + + /** + * Returns the call arguments as a JSON string. + * + * @return call arguments (JSON string) + */ + public String getToolArgs() { + return toolArgs; + } + + /** + * Returns the tool category. + * + * @return tool category + */ + public String getToolType() { + return toolType; + } + + /** + * Returns the progress text. + * + * @return progress text + */ + public String getTips() { + return tips; + } + + /** + * Returns the short tags; may be empty. + * + * @return short tags + */ + public String[] getTipChips() { + return tipChips; + } + + /** + * Returns the round number. + * + * @return round number + */ + public int getIteration() { + return iteration; + } + + /** + * Returns whether the call happened during the thinking phase. + * + * @return {@code true} if the call happened during the thinking phase + */ + public boolean isThinking() { + return isThinking; + } + + /** + * Returns the filtered call results, for display. + * + * @return filtered call results + */ + public NodeToolUseOutputs getOutputs() { + return outputs; + } + + @Override + public String toString() { + return "NodeToolUseFinishedEvent [toolUseId=" + toolUseId + ", status=" + status + ", error=" + error + + ", elapsedTime=" + elapsedTime + ", startedAt=" + startedAt + ", toolName=" + toolName + + ", toolFuncName=" + toolFuncName + ", toolArgs=" + toolArgs + ", toolType=" + toolType + ", tips=" + + tips + ", tipChips=" + Arrays.toString(tipChips) + ", iteration=" + iteration + ", isThinking=" + + isThinking + ", outputs=" + outputs + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseOutputs.java b/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseOutputs.java new file mode 100644 index 0000000000..3b7937ee80 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseOutputs.java @@ -0,0 +1,78 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * The {@code outputs} sub-object of a {@link NodeToolUseFinishedEvent} — + * only carries fields meant for display. + */ +public class NodeToolUseOutputs { + private Reference[] references; + private String[] referenceDomains; + private String query; + private String text; + private String toolArgs; + private String data; + + /** + * Returns the sources referenced by the tool result. + * + * @return referenced sources + */ + public Reference[] getReferences() { + return references; + } + + /** + * Returns the domains of the referenced sources. + * + * @return domains of the referenced sources + */ + public String[] getReferenceDomains() { + return referenceDomains; + } + + /** + * Returns the query the tool executed. + * + * @return the query the tool executed, or {@code null} + */ + public String getQuery() { + return query; + } + + /** + * Returns the raw response text of the tool. + * + * @return raw response text of the tool, or {@code null} + */ + public String getText() { + return text; + } + + /** + * Returns the parsed request arguments, as JSON text. + * + * @return parsed request arguments (JSON text), or {@code null} + */ + public String getToolArgs() { + return toolArgs; + } + + /** + * Returns the structured result, as JSON text; present only for selected + * tools. + * + * @return structured result (JSON text), or {@code null} + */ + public String getData() { + return data; + } + + @Override + public String toString() { + return "NodeToolUseOutputs [references=" + Arrays.toString(references) + ", referenceDomains=" + + Arrays.toString(referenceDomains) + ", query=" + query + ", text=" + text + ", toolArgs=" + toolArgs + + ", data=" + data + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseStartedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseStartedEvent.java new file mode 100644 index 0000000000..fa8a415395 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/NodeToolUseStartedEvent.java @@ -0,0 +1,100 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * An ordinary tool call has started. Match it to its + * {@link NodeToolUseFinishedEvent} counterpart by {@link #getToolUseId}. + */ +public final class NodeToolUseStartedEvent extends ConversationStreamEvent { + private String toolUseId; + private String toolName; + private String toolFuncName; + private String toolArgs; + private String tips; + private String[] tipChips; + private int iteration; + private long startedAt; + + /** + * Returns the unique ID of this call; matches the finished event. + * + * @return unique ID of this call + */ + public String getToolUseId() { + return toolUseId; + } + + /** + * Returns the localized display name of the tool. + * + * @return localized display name of the tool + */ + public String getToolName() { + return toolName; + } + + /** + * Returns the locale-stable tool identifier; use this for logic keyed on + * the tool kind. + * + * @return locale-stable tool identifier + */ + public String getToolFuncName() { + return toolFuncName; + } + + /** + * Returns the call arguments as a JSON string. + * + * @return call arguments (JSON string) + */ + public String getToolArgs() { + return toolArgs; + } + + /** + * Returns progress text suitable for direct display, e.g. + * {@code "Searching the web..."}. + * + * @return progress text + */ + public String getTips() { + return tips; + } + + /** + * Returns the short tags accompanying {@link #getTips}; may be empty. + * + * @return short tags accompanying the tips + */ + public String[] getTipChips() { + return tipChips; + } + + /** + * Returns the round number. Calls in the same round (same + * {@link #getIteration}) run in parallel. + * + * @return round number + */ + public int getIteration() { + return iteration; + } + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + @Override + public String toString() { + return "NodeToolUseStartedEvent [toolUseId=" + toolUseId + ", toolName=" + toolName + ", toolFuncName=" + + toolFuncName + ", toolArgs=" + toolArgs + ", tips=" + tips + ", tipChips=" + + Arrays.toString(tipChips) + ", iteration=" + iteration + ", startedAt=" + startedAt + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/OtherEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/OtherEvent.java new file mode 100644 index 0000000000..1132dbcfa6 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/OtherEvent.java @@ -0,0 +1,36 @@ +package com.longbridge.agent; + +/** + * An event type not recognized by this SDK version, carried as raw JSON text + * so callers aren't broken by future additions to the API. + */ +public final class OtherEvent extends ConversationStreamEvent { + private String event; + private String json; + + /** + * Returns the SSE envelope's {@code event} field (the event type name) of + * whatever event type this SDK version doesn't yet recognize as one of + * the other {@link ConversationStreamEvent} subclasses (see its class + * documentation for the full list). + * + * @return event type name + */ + public String getEvent() { + return event; + } + + /** + * Returns the raw event payload as JSON text. + * + * @return raw event payload (JSON text) + */ + public String getJson() { + return json; + } + + @Override + public String toString() { + return "OtherEvent [event=" + event + ", json=" + json + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/PingEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/PingEvent.java new file mode 100644 index 0000000000..d85625f566 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/PingEvent.java @@ -0,0 +1,12 @@ +package com.longbridge.agent; + +/** + * A heartbeat with no payload, observed at arbitrary points in the stream + * (including in between {@link MessageEvent} chunks). + */ +public final class PingEvent extends ConversationStreamEvent { + @Override + public String toString() { + return "PingEvent []"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/PlanChangedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/PlanChangedEvent.java new file mode 100644 index 0000000000..ca63485f25 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/PlanChangedEvent.java @@ -0,0 +1,53 @@ +package com.longbridge.agent; + +/** + * The Agent created or updated its task plan. + */ +public final class PlanChangedEvent extends ConversationStreamEvent { + private String nodeId; + private long startedAt; + private String outputs; + private String toolName; + + /** + * Returns the ID of the planning node. + * + * @return ID of the planning node + */ + public String getNodeId() { + return nodeId; + } + + /** + * Returns the time of the change, Unix timestamp in seconds. + * + * @return time of the change + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns the current plan content, as JSON text. + * + * @return current plan content (JSON text), or {@code null} + */ + public String getOutputs() { + return outputs; + } + + /** + * Returns the identifier of the planning tool. + * + * @return identifier of the planning tool + */ + public String getToolName() { + return toolName; + } + + @Override + public String toString() { + return "PlanChangedEvent [nodeId=" + nodeId + ", startedAt=" + startedAt + ", outputs=" + outputs + + ", toolName=" + toolName + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/QueryMaskedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/QueryMaskedEvent.java new file mode 100644 index 0000000000..f486a9803e --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/QueryMaskedEvent.java @@ -0,0 +1,33 @@ +package com.longbridge.agent; + +/** + * Sensitive content in the user query was masked before processing. Display + * {@link #getMaskedQuery} instead of the original query. + */ +public final class QueryMaskedEvent extends ConversationStreamEvent { + private String rawQuery; + private String maskedQuery; + + /** + * Returns the original user query. + * + * @return original user query + */ + public String getRawQuery() { + return rawQuery; + } + + /** + * Returns the masked query. + * + * @return masked query + */ + public String getMaskedQuery() { + return maskedQuery; + } + + @Override + public String toString() { + return "QueryMaskedEvent [rawQuery=" + rawQuery + ", maskedQuery=" + maskedQuery + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/Question.java b/java/javasrc/src/main/java/com/longbridge/agent/Question.java new file mode 100644 index 0000000000..5f1e9b40bd --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/Question.java @@ -0,0 +1,45 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * One question the Agent needs you to answer + */ +public class Question { + private String question; + private QuestionOption[] options; + private boolean multiSelect; + + /** + * Returns the question text. + * + * @return question text + */ + public String getQuestion() { + return question; + } + + /** + * Returns the options; empty means free-form answer. + * + * @return options + */ + public QuestionOption[] getOptions() { + return options; + } + + /** + * Returns whether multiple options may be selected. + * + * @return {@code true} if multiple options may be selected + */ + public boolean isMultiSelect() { + return multiSelect; + } + + @Override + public String toString() { + return "Question [question=" + question + ", options=" + Arrays.toString(options) + ", multiSelect=" + + multiSelect + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/QuestionOption.java b/java/javasrc/src/main/java/com/longbridge/agent/QuestionOption.java new file mode 100644 index 0000000000..cef61631f5 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/QuestionOption.java @@ -0,0 +1,22 @@ +package com.longbridge.agent; + +/** + * One option of a {@link Question} + */ +public class QuestionOption { + private String description; + + /** + * Returns the option text. + * + * @return option text + */ + public String getDescription() { + return description; + } + + @Override + public String toString() { + return "QuestionOption [description=" + description + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/Reference.java b/java/javasrc/src/main/java/com/longbridge/agent/Reference.java new file mode 100644 index 0000000000..35ca58a579 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/Reference.java @@ -0,0 +1,42 @@ +package com.longbridge.agent; + +/** + * A source referenced by the answer + */ +public class Reference { + private int index; + private String title; + private String url; + + /** + * Returns the reference index. + * + * @return reference index + */ + public int getIndex() { + return index; + } + + /** + * Returns the reference title. + * + * @return reference title + */ + public String getTitle() { + return title; + } + + /** + * Returns the reference URL. + * + * @return reference URL + */ + public String getUrl() { + return url; + } + + @Override + public String toString() { + return "Reference [index=" + index + ", title=" + title + ", url=" + url + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/SubagentFinishedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/SubagentFinishedEvent.java new file mode 100644 index 0000000000..b1b7d4f4f1 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/SubagentFinishedEvent.java @@ -0,0 +1,86 @@ +package com.longbridge.agent; + +/** + * The subagent has finished its sub-task. + */ +public final class SubagentFinishedEvent extends ConversationStreamEvent { + private String nodeId; + private String toolUseId; + private String status; + private long startedAt; + private double elapsedTime; + private String error; + private SubagentOutputs outputs; + + /** + * Returns the ID of the node that spawned the subagent. + * + * @return ID of the node that spawned the subagent + */ + public String getNodeId() { + return nodeId; + } + + /** + * Returns the ID matching the {@code toolUseId} of + * {@link SubagentStartedEvent}. + * + * @return matching tool use ID + */ + public String getToolUseId() { + return toolUseId; + } + + /** + * Returns the status: {@code succeeded} / {@code failed}. + * + * @return status + */ + public String getStatus() { + return status; + } + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns the total subagent duration in seconds. + * + * @return total subagent duration in seconds + */ + public double getElapsedTime() { + return elapsedTime; + } + + /** + * Returns the error description on failure. + * + * @return error description + */ + public String getError() { + return error; + } + + /** + * Returns the subagent result: goal, result, and the timeline of tool + * calls it made. + * + * @return subagent result + */ + public SubagentOutputs getOutputs() { + return outputs; + } + + @Override + public String toString() { + return "SubagentFinishedEvent [nodeId=" + nodeId + ", toolUseId=" + toolUseId + ", status=" + status + + ", startedAt=" + startedAt + ", elapsedTime=" + elapsedTime + ", error=" + error + ", outputs=" + + outputs + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/SubagentOutputs.java b/java/javasrc/src/main/java/com/longbridge/agent/SubagentOutputs.java new file mode 100644 index 0000000000..2788a38c64 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/SubagentOutputs.java @@ -0,0 +1,46 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * The {@code outputs} sub-object of a {@link SubagentFinishedEvent}. + */ +public class SubagentOutputs { + private String goal; + private String result; + private String[] subagentTools; + + /** + * Returns the goal that was assigned to the subagent. + * + * @return the goal that was assigned to the subagent, or {@code null} + */ + public String getGoal() { + return goal; + } + + /** + * Returns the subagent's result. + * + * @return the subagent's result, or {@code null} + */ + public String getResult() { + return result; + } + + /** + * Returns the timeline of tool calls the subagent made, each as JSON + * text. + * + * @return timeline of tool calls the subagent made + */ + public String[] getSubagentTools() { + return subagentTools; + } + + @Override + public String toString() { + return "SubagentOutputs [goal=" + goal + ", result=" + result + ", subagentTools=" + + Arrays.toString(subagentTools) + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/SubagentProgressEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/SubagentProgressEvent.java new file mode 100644 index 0000000000..b06ae9bdef --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/SubagentProgressEvent.java @@ -0,0 +1,97 @@ +package com.longbridge.agent; + +/** + * Emitted every time the subagent calls one of its own tools. Use it to + * render a live timeline inside the subagent card. + */ +public final class SubagentProgressEvent extends ConversationStreamEvent { + private String nodeId; + private String parentToolCallId; + private String subagentToolName; + private String subagentToolArgs; + private String subagentStatus; + private long subagentDurationMs; + private int subagentIteration; + private long startedAt; + + /** + * Returns the ID of the node that spawned the subagent. + * + * @return ID of the node that spawned the subagent + */ + public String getNodeId() { + return nodeId; + } + + /** + * Returns the {@code toolUseId} of the owning {@link SubagentStartedEvent}. + * + * @return {@code toolUseId} of the owning {@link SubagentStartedEvent} + */ + public String getParentToolCallId() { + return parentToolCallId; + } + + /** + * Returns the name of the tool the subagent called. + * + * @return name of the tool the subagent called + */ + public String getSubagentToolName() { + return subagentToolName; + } + + /** + * Returns the arguments of that call, as a JSON string. + * + * @return arguments of that call (JSON string) + */ + public String getSubagentToolArgs() { + return subagentToolArgs; + } + + /** + * Returns the status of that call: {@code running} / {@code succeeded} / + * {@code failed}. + * + * @return status of that call + */ + public String getSubagentStatus() { + return subagentStatus; + } + + /** + * Returns the duration of that call in milliseconds. + * + * @return duration of that call in milliseconds + */ + public long getSubagentDurationMs() { + return subagentDurationMs; + } + + /** + * Returns the subagent's internal round number. + * + * @return subagent's internal round number + */ + public int getSubagentIteration() { + return subagentIteration; + } + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + @Override + public String toString() { + return "SubagentProgressEvent [nodeId=" + nodeId + ", parentToolCallId=" + parentToolCallId + + ", subagentToolName=" + subagentToolName + ", subagentToolArgs=" + subagentToolArgs + + ", subagentStatus=" + subagentStatus + ", subagentDurationMs=" + subagentDurationMs + + ", subagentIteration=" + subagentIteration + ", startedAt=" + startedAt + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/SubagentStartedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/SubagentStartedEvent.java new file mode 100644 index 0000000000..7320c2fb92 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/SubagentStartedEvent.java @@ -0,0 +1,89 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * The Agent has spawned a subagent to work on a sub-task. When the Agent + * spawns a subagent, the subagent's lifecycle is reported with this + * dedicated event family instead of {@code node_tool_use_*}. + */ +public final class SubagentStartedEvent extends ConversationStreamEvent { + private String nodeId; + private String toolUseId; + private long startedAt; + private String goal; + private String prompt; + private String subagentId; + private String[] tools; + + /** + * Returns the ID of the node that spawned the subagent. + * + * @return ID of the node that spawned the subagent + */ + public String getNodeId() { + return nodeId; + } + + /** + * Returns the unique ID of this spawn; matches the finished event. + * + * @return unique ID of this spawn + */ + public String getToolUseId() { + return toolUseId; + } + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns the goal assigned to the subagent. + * + * @return goal assigned to the subagent + */ + public String getGoal() { + return goal; + } + + /** + * Returns the full task prompt given to the subagent. + * + * @return full task prompt given to the subagent + */ + public String getPrompt() { + return prompt; + } + + /** + * Returns the subagent identifier; may be empty. + * + * @return subagent identifier + */ + public String getSubagentId() { + return subagentId; + } + + /** + * Returns the tools granted to the subagent, each as JSON text; may be + * empty. + * + * @return tools granted to the subagent + */ + public String[] getTools() { + return tools; + } + + @Override + public String toString() { + return "SubagentStartedEvent [nodeId=" + nodeId + ", toolUseId=" + toolUseId + ", startedAt=" + startedAt + + ", goal=" + goal + ", prompt=" + prompt + ", subagentId=" + subagentId + ", tools=" + + Arrays.toString(tools) + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ThinkingFinishedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/ThinkingFinishedEvent.java new file mode 100644 index 0000000000..e91a8d0cc1 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ThinkingFinishedEvent.java @@ -0,0 +1,33 @@ +package com.longbridge.agent; + +/** + * The reasoning phase is over; answer text ({@link MessageEvent} with + * {@code messageType == "answer"}) follows. + */ +public final class ThinkingFinishedEvent extends ConversationStreamEvent { + private long finishedAt; + private int elapsedTime; + + /** + * Returns the finish time, Unix timestamp in seconds. + * + * @return finish time + */ + public long getFinishedAt() { + return finishedAt; + } + + /** + * Returns the reasoning duration in seconds. + * + * @return reasoning duration in seconds + */ + public int getElapsedTime() { + return elapsedTime; + } + + @Override + public String toString() { + return "ThinkingFinishedEvent [finishedAt=" + finishedAt + ", elapsedTime=" + elapsedTime + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/ThinkingStartedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/ThinkingStartedEvent.java new file mode 100644 index 0000000000..7cc404331b --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/ThinkingStartedEvent.java @@ -0,0 +1,25 @@ +package com.longbridge.agent; + +/** + * The Agent has entered the reasoning phase (analyzing the question, planning + * tool calls). Between this and a {@link ThinkingFinishedEvent}, + * {@link MessageEvent}s with {@code messageType == "think"} and tool-call + * events may arrive. + */ +public final class ThinkingStartedEvent extends ConversationStreamEvent { + private long startedAt; + + /** + * Returns the start time, Unix timestamp in seconds. + * + * @return start time + */ + public long getStartedAt() { + return startedAt; + } + + @Override + public String toString() { + return "ThinkingStartedEvent [startedAt=" + startedAt + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/WorkflowFinishedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/WorkflowFinishedEvent.java new file mode 100644 index 0000000000..b0b8d62086 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/WorkflowFinishedEvent.java @@ -0,0 +1,29 @@ +package com.longbridge.agent; + +/** + * The run finished (succeeded, interrupted, failed, or stopped), carrying the + * run's outcome. Not necessarily the last event of the stream — the server + * may still emit a few more housekeeping events (e.g. a + * {@link ChatTitleUpdatedEvent}) before actually closing the connection + * (unless the stream itself errors first, delivered instead via + * {@code Flow.Subscriber#onError}). + */ +public final class WorkflowFinishedEvent extends ConversationStreamEvent { + private ConversationResponse response; + + /** + * Returns the final conversation response, equivalent to what a blocking + * call to {@link AgentContext#conversation} or + * {@link AgentContext#continueConversation} would have returned. + * + * @return final conversation response + */ + public ConversationResponse getResponse() { + return response; + } + + @Override + public String toString() { + return "WorkflowFinishedEvent [response=" + response + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/WorkflowStartedEvent.java b/java/javasrc/src/main/java/com/longbridge/agent/WorkflowStartedEvent.java new file mode 100644 index 0000000000..1462364fc9 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/WorkflowStartedEvent.java @@ -0,0 +1,53 @@ +package com.longbridge.agent; + +/** + * Observed right after a {@link ChatStartedEvent} on every run seen so far. + */ +public final class WorkflowStartedEvent extends ConversationStreamEvent { + private boolean hitCache; + private WorkflowStartedInputs inputs; + private long startedAt; + private long workflowId; + + /** + * Returns whether this run's answer was served from a cache. + * + * @return {@code true} if this run's answer was served from a cache + */ + public boolean isHitCache() { + return hitCache; + } + + /** + * Returns the echoed inputs of the run. + * + * @return the echoed inputs of the run + */ + public WorkflowStartedInputs getInputs() { + return inputs; + } + + /** + * Returns the Unix timestamp (in seconds) at which the run started. + * + * @return Unix timestamp (in seconds) at which the run started + */ + public long getStartedAt() { + return startedAt; + } + + /** + * Returns the internal workflow run ID. + * + * @return internal workflow run ID + */ + public long getWorkflowId() { + return workflowId; + } + + @Override + public String toString() { + return "WorkflowStartedEvent [hitCache=" + hitCache + ", inputs=" + inputs + ", startedAt=" + startedAt + + ", workflowId=" + workflowId + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/WorkflowStartedInputs.java b/java/javasrc/src/main/java/com/longbridge/agent/WorkflowStartedInputs.java new file mode 100644 index 0000000000..be284f2576 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/WorkflowStartedInputs.java @@ -0,0 +1,54 @@ +package com.longbridge.agent; + +/** + * The {@code inputs} sub-object of a {@link WorkflowStartedEvent}, echoing + * the run's inputs. + */ +public class WorkflowStartedInputs { + private long chatId; + private String chatUid; + private String messageId; + private String query; + + /** + * Returns the ID of the owning conversation. + * + * @return conversation ID + */ + public long getChatId() { + return chatId; + } + + /** + * Returns the conversation identifier. + * + * @return conversation identifier + */ + public String getChatUid() { + return chatUid; + } + + /** + * Returns the message ID of this round. + * + * @return message ID + */ + public String getMessageId() { + return messageId; + } + + /** + * Returns the question that was asked. + * + * @return the question that was asked + */ + public String getQuery() { + return query; + } + + @Override + public String toString() { + return "WorkflowStartedInputs [chatId=" + chatId + ", chatUid=" + chatUid + ", messageId=" + messageId + + ", query=" + query + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/Workspace.java b/java/javasrc/src/main/java/com/longbridge/agent/Workspace.java new file mode 100644 index 0000000000..0339e2cc01 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/Workspace.java @@ -0,0 +1,53 @@ +package com.longbridge.agent; + +/** + * A Workspace the current account belongs to + */ +public class Workspace { + private String id; + private String name; + private long createdAt; + private long updatedAt; + + /** + * Returns the Workspace ID. + * + * @return Workspace ID + */ + public String getId() { + return id; + } + + /** + * Returns the Workspace name. + * + * @return Workspace name + */ + public String getName() { + return name; + } + + /** + * Returns the creation time, Unix timestamp in seconds. + * + * @return creation time + */ + public long getCreatedAt() { + return createdAt; + } + + /** + * Returns the last updated time, Unix timestamp in seconds. + * + * @return last updated time + */ + public long getUpdatedAt() { + return updatedAt; + } + + @Override + public String toString() { + return "Workspace [id=" + id + ", name=" + name + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/agent/WorkspacesResponse.java b/java/javasrc/src/main/java/com/longbridge/agent/WorkspacesResponse.java new file mode 100644 index 0000000000..ef46784e3c --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/agent/WorkspacesResponse.java @@ -0,0 +1,24 @@ +package com.longbridge.agent; + +import java.util.Arrays; + +/** + * Response for {@link AgentContext#workspaces} + */ +public class WorkspacesResponse { + private Workspace[] workspaces; + + /** + * Returns the Workspaces the current account belongs to. + * + * @return Workspace list + */ + public Workspace[] getWorkspaces() { + return workspaces; + } + + @Override + public String toString() { + return "WorkspacesResponse [workspaces=" + Arrays.toString(workspaces) + "]"; + } +} diff --git a/java/src/agent_context.rs b/java/src/agent_context.rs new file mode 100644 index 0000000000..9e7d3fe04a --- /dev/null +++ b/java/src/agent_context.rs @@ -0,0 +1,370 @@ +use std::sync::Arc; + +use jni::{ + JNIEnv, JavaVM, + objects::{GlobalRef, JClass, JObject, JString, JValue}, +}; +use longbridge::{ + AgentContext, Config, + agent::{self, AnswersByToolCall, ConversationStreamEvent, GetAgentsOptions}, +}; + +use crate::{ + async_util, + error::{JniError, jni_result}, + init::CONVERSATION_STREAM_SUBSCRIPTION_CLASS, + types::{FromJValue, IntoJValue, JavaInteger, get_field}, +}; + +struct ContextObj { + ctx: AgentContext, +} + +/// Everything needed to deliver `Flow.Subscriber` callbacks (`onNext` / +/// `onError` / `onComplete`) from a background tokio task back into the JVM. +/// Shared (via `Arc`) between the three closures handed to +/// [`agent::ConversationStreamSubscription::spawn`], since they all target +/// the same `Subscriber` instance. +struct SubscriberSink { + jvm: JavaVM, + subscriber: GlobalRef, +} + +fn deliver_on_next(sink: &SubscriberSink, event: ConversationStreamEvent) { + let Ok(mut env) = sink.jvm.attach_current_thread() else { + return; + }; + if let Ok(value) = event.into_jvalue(&mut env) { + // `Flow.Subscriber.onNext(T)` is generic; the JVM always exposes a + // bridge method with the erased `(Ljava/lang/Object;)V` descriptor + // for any concrete implementation, so this is the correct signature + // to target regardless of what `T` the caller's `Subscriber` names. + let _ = env.call_method( + &sink.subscriber, + "onNext", + "(Ljava/lang/Object;)V", + &[value.borrow()], + ); + } +} + +fn deliver_on_error(sink: &SubscriberSink, err: longbridge::Error) { + let Ok(mut env) = sink.jvm.attach_current_thread() else { + return; + }; + let err_obj = JniError::from(err).into_error_object(&mut env); + let _ = env.call_method( + &sink.subscriber, + "onError", + "(Ljava/lang/Throwable;)V", + &[JValue::from(&err_obj)], + ); +} + +fn deliver_on_complete(sink: &SubscriberSink) { + let Ok(mut env) = sink.jvm.attach_current_thread() else { + return; + }; + let _ = env.call_method(&sink.subscriber, "onComplete", "()V", &[]); +} + +fn new_subscription_object<'a>( + env: &mut JNIEnv<'a>, + handle: i64, +) -> jni::errors::Result> { + let cls = CONVERSATION_STREAM_SUBSCRIPTION_CLASS.get().unwrap(); + env.new_object(cls, "(J)V", &[JValue::from(handle)]) +} + +/// Finish a successful `subscribe()`: box up `subscription` behind an opaque +/// handle, wrap it in a Java `ConversationStreamSubscription`, and deliver it +/// via `onSubscribe`. If attaching to the JVM or constructing the Java object +/// fails, the boxed subscription is freed immediately so it isn't leaked (the +/// subscriber will simply never hear back, which is the best we can do at +/// that point). +fn finish_subscribe_success( + sink: Arc, + subscription: agent::ConversationStreamSubscription, +) { + let handle = Box::into_raw(Box::new(subscription)) as i64; + + let Ok(mut env) = sink.jvm.attach_current_thread() else { + unsafe { + let _ = Box::from_raw(handle as *mut agent::ConversationStreamSubscription); + } + return; + }; + + match new_subscription_object(&mut env, handle) { + Ok(obj) => { + let _ = env.call_method( + &sink.subscriber, + "onSubscribe", + "(Ljava/util/concurrent/Flow$Subscription;)V", + &[JValue::from(&obj)], + ); + } + Err(_) => unsafe { + let _ = Box::from_raw(handle as *mut agent::ConversationStreamSubscription); + }, + } +} + +fn read_get_agents_options( + env: &mut JNIEnv, + opts: &JObject, +) -> jni::errors::Result> { + if opts.is_null() { + return Ok(None); + } + + let mut new_opts = GetAgentsOptions::new(); + let page: Option = get_field(env, opts, "page")?; + if let Some(page) = page { + new_opts = new_opts.page(page.into()); + } + let limit: Option = get_field(env, opts, "limit")?; + if let Some(limit) = limit { + new_opts = new_opts.limit(limit.into()); + } + let name: Option = get_field(env, opts, "name")?; + if let Some(name) = name { + new_opts = new_opts.name(name); + } + Ok(Some(new_opts)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_newAgentContext( + mut env: JNIEnv, + _class: JClass, + config: i64, +) -> i64 { + jni_result(&mut env, 0i64, |_env| { + let config = Arc::new((*(config as *const Config)).clone()); + Ok(Box::into_raw(Box::new(ContextObj { + ctx: AgentContext::new(config), + })) as i64) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_freeAgentContext( + _env: JNIEnv, + _class: JClass, + ctx: i64, +) { + let _ = Box::from_raw(ctx as *mut ContextObj); +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_agentContextWorkspaces( + mut env: JNIEnv, + _class: JClass, + context: i64, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + async_util::execute( + env, + callback, + async move { Ok(context.ctx.workspaces().await?) }, + )?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_agentContextAgents( + mut env: JNIEnv, + _class: JClass, + context: i64, + workspace_id: JString, + opts: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let workspace_id: String = FromJValue::from_jvalue(env, workspace_id.into())?; + let opts = read_get_agents_options(env, &opts)?; + async_util::execute(env, callback, async move { + Ok(context.ctx.agents(workspace_id, opts).await?) + })?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_agentContextConversation( + mut env: JNIEnv, + _class: JClass, + context: i64, + agent_id: JString, + query: JString, + chat_uid: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let agent_id: String = FromJValue::from_jvalue(env, agent_id.into())?; + let query: String = FromJValue::from_jvalue(env, query.into())?; + let chat_uid: Option = FromJValue::from_jvalue(env, chat_uid.into())?; + async_util::execute(env, callback, async move { + Ok(crate::types::ConversationResponse::from( + context.ctx.conversation(agent_id, query, chat_uid).await?, + )) + })?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_agentContextContinueConversation( + mut env: JNIEnv, + _class: JClass, + context: i64, + agent_id: JString, + chat_uid: JString, + message_id: JString, + answers_json: JString, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let agent_id: String = FromJValue::from_jvalue(env, agent_id.into())?; + let chat_uid: String = FromJValue::from_jvalue(env, chat_uid.into())?; + let message_id: String = FromJValue::from_jvalue(env, message_id.into())?; + let answers_json: String = FromJValue::from_jvalue(env, answers_json.into())?; + let answers: AnswersByToolCall = serde_json::from_str(&answers_json).unwrap_or_default(); + async_util::execute(env, callback, async move { + Ok(crate::types::ConversationResponse::from( + context + .ctx + .continue_conversation(agent_id, chat_uid, message_id, answers) + .await?, + )) + })?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_agentContextConversationStreamSubscribe( + mut env: JNIEnv, + _class: JClass, + context: i64, + agent_id: JString, + query: JString, + chat_uid: JObject, + subscriber: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let ctx = context.ctx.clone(); + let agent_id: String = FromJValue::from_jvalue(env, agent_id.into())?; + let query: String = FromJValue::from_jvalue(env, query.into())?; + let chat_uid: Option = FromJValue::from_jvalue(env, chat_uid.into())?; + let jvm = env.get_java_vm()?; + let subscriber = env.new_global_ref(subscriber)?; + + // `subscribe()` must return immediately without doing I/O (cold + // Publisher semantics) — the actual connect happens on the shared + // runtime, and `onSubscribe`/`onError` are delivered asynchronously + // once it resolves. + longbridge::runtime_handle().spawn(async move { + let sink = Arc::new(SubscriberSink { jvm, subscriber }); + match ctx.conversation_streamed(agent_id, query, chat_uid).await { + Ok(stream) => { + let (sink_next, sink_err, sink_complete) = + (sink.clone(), sink.clone(), sink.clone()); + let subscription = agent::ConversationStreamSubscription::spawn( + stream, + move |event| deliver_on_next(&sink_next, event), + move |err| deliver_on_error(&sink_err, err), + move || deliver_on_complete(&sink_complete), + ); + finish_subscribe_success(sink, subscription); + } + Err(err) => deliver_on_error(&sink, err), + } + }); + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_agentContextContinueConversationStreamSubscribe( + mut env: JNIEnv, + _class: JClass, + context: i64, + agent_id: JString, + chat_uid: JString, + message_id: JString, + answers_json: JString, + subscriber: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let ctx = context.ctx.clone(); + let agent_id: String = FromJValue::from_jvalue(env, agent_id.into())?; + let chat_uid: String = FromJValue::from_jvalue(env, chat_uid.into())?; + let message_id: String = FromJValue::from_jvalue(env, message_id.into())?; + let answers_json: String = FromJValue::from_jvalue(env, answers_json.into())?; + let answers: AnswersByToolCall = serde_json::from_str(&answers_json).unwrap_or_default(); + let jvm = env.get_java_vm()?; + let subscriber = env.new_global_ref(subscriber)?; + + longbridge::runtime_handle().spawn(async move { + let sink = Arc::new(SubscriberSink { jvm, subscriber }); + match ctx + .continue_conversation_streamed(agent_id, chat_uid, message_id, answers) + .await + { + Ok(stream) => { + let (sink_next, sink_err, sink_complete) = + (sink.clone(), sink.clone(), sink.clone()); + let subscription = agent::ConversationStreamSubscription::spawn( + stream, + move |event| deliver_on_next(&sink_next, event), + move |err| deliver_on_error(&sink_err, err), + move || deliver_on_complete(&sink_complete), + ); + finish_subscribe_success(sink, subscription); + } + Err(err) => deliver_on_error(&sink, err), + } + }); + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_conversationStreamSubscriptionRequest( + _env: JNIEnv, + _class: JClass, + handle: i64, + n: i64, +) { + let subscription = &*(handle as *const agent::ConversationStreamSubscription); + subscription.request(n.max(0) as u64); +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_conversationStreamSubscriptionCancel( + _env: JNIEnv, + _class: JClass, + handle: i64, +) { + let subscription = &*(handle as *const agent::ConversationStreamSubscription); + subscription.cancel(); +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_freeConversationStreamSubscription( + _env: JNIEnv, + _class: JClass, + handle: i64, +) { + let _ = Box::from_raw(handle as *mut agent::ConversationStreamSubscription); +} diff --git a/java/src/init.rs b/java/src/init.rs index 50c5653ac9..15b1959144 100644 --- a/java/src/init.rs +++ b/java/src/init.rs @@ -20,6 +20,7 @@ pub(crate) static TIME_LOCALDATETIME_CLASS: OnceLock = OnceLock::new( pub(crate) static TIME_ZONE_ID: OnceLock = OnceLock::new(); pub(crate) static DERIVATIVE_TYPE_CLASS: OnceLock = OnceLock::new(); pub(crate) static OPENAPI_EXCEPTION_CLASS: OnceLock = OnceLock::new(); +pub(crate) static CONVERSATION_STREAM_SUBSCRIPTION_CLASS: OnceLock = OnceLock::new(); fn init_timezone_id(env: &mut JNIEnv) { let utc = env.new_string("UTC").unwrap(); @@ -68,7 +69,11 @@ pub extern "system" fn Java_com_longbridge_SdkNative_init<'a>( (TIME_LOCALTIME_CLASS, "java/time/LocalTime"), (TIME_LOCALDATETIME_CLASS, "java/time/LocalDateTime"), (DERIVATIVE_TYPE_CLASS, "com/longbridge/quote/DerivativeType"), - (OPENAPI_EXCEPTION_CLASS, "com/longbridge/OpenApiException") + (OPENAPI_EXCEPTION_CLASS, "com/longbridge/OpenApiException"), + ( + CONVERSATION_STREAM_SUBSCRIPTION_CLASS, + "com/longbridge/agent/ConversationStreamSubscription" + ) ); init_timezone_id(&mut env); @@ -124,7 +129,8 @@ pub extern "system" fn Java_com_longbridge_SdkNative_init<'a>( longbridge::dca::types::DCAStatus, longbridge::alert::types::AlertCondition, longbridge::alert::types::AlertFrequency, - longbridge::calendar::types::CalendarCategory + longbridge::calendar::types::CalendarCategory, + longbridge::agent::ConversationStatus ); // classes @@ -408,6 +414,43 @@ pub extern "system" fn Java_com_longbridge_SdkNative_init<'a>( longbridge::portfolio::FlowItem, longbridge::portfolio::ProfitAnalysisFlows, // DCAContext - longbridge::dca::DcaCreateResult + longbridge::dca::DcaCreateResult, + // AgentContext + longbridge::agent::Workspace, + longbridge::agent::WorkspacesResponse, + longbridge::agent::Agent, + longbridge::agent::AgentsResponse, + longbridge::agent::Reference, + longbridge::agent::QuestionOption, + longbridge::agent::Question, + longbridge::agent::Interrupt, + longbridge::agent::AgentError, + longbridge::agent::ChatStartedPayload, + longbridge::agent::WorkflowStartedInputs, + longbridge::agent::WorkflowStartedPayload, + longbridge::agent::MessagePayload, + crate::types::PingEvent, + longbridge::agent::ThinkingStartedPayload, + longbridge::agent::ThinkingFinishedPayload, + longbridge::agent::NodeToolUseStartedPayload, + crate::types::NodeToolUseOutputs, + longbridge::agent::NodeToolUseFinishedPayload, + longbridge::agent::SubagentStartedPayload, + longbridge::agent::SubagentProgressPayload, + crate::types::SubagentOutputs, + longbridge::agent::SubagentFinishedPayload, + longbridge::agent::AgentToolStartedPayload, + longbridge::agent::AgentToolProgressPayload, + longbridge::agent::AgentToolFinishedPayload, + crate::types::ConversationResponse, + crate::types::HumanInteractionRequiredEvent, + longbridge::agent::QueryMaskedPayload, + longbridge::agent::PlanChangedPayload, + longbridge::agent::ContextCompressStartedPayload, + longbridge::agent::ContextCompressFinishedPayload, + longbridge::agent::ChatFinishedPayload, + crate::types::WorkflowFinishedEvent, + longbridge::agent::ChatTitleUpdatedPayload, + crate::types::OtherEvent ); } diff --git a/java/src/lib.rs b/java/src/lib.rs index c4fcdf96f8..daaaf39998 100644 --- a/java/src/lib.rs +++ b/java/src/lib.rs @@ -1,6 +1,7 @@ #![allow(clippy::missing_safety_doc)] #![allow(unsafe_op_in_unsafe_fn)] +mod agent_context; mod alert_context; mod asset_context; mod async_util; diff --git a/java/src/types/classes.rs b/java/src/types/classes.rs index e5ce0e338a..8b0aefed63 100644 --- a/java/src/types/classes.rs +++ b/java/src/types/classes.rs @@ -2873,3 +2873,545 @@ impl_java_class!( longbridge::screener::ScreenerIndicatorsResponse, [data] ); + +// ── AgentContext types ───────────────────────────────────────────── + +impl_java_class!( + "com/longbridge/agent/Workspace", + longbridge::agent::Workspace, + [id, name, created_at, updated_at] +); + +impl_java_class!( + "com/longbridge/agent/WorkspacesResponse", + longbridge::agent::WorkspacesResponse, + [ + #[java(objarray)] + workspaces + ] +); + +impl_java_class!( + "com/longbridge/agent/Agent", + longbridge::agent::Agent, + [ + uid, + name, + description, + mode, + icon, + is_published, + published_at, + created_at, + updated_at + ] +); + +impl_java_class!( + "com/longbridge/agent/AgentsResponse", + longbridge::agent::AgentsResponse, + [ + #[java(objarray)] + agents, + total + ] +); + +impl_java_class!( + "com/longbridge/agent/Reference", + longbridge::agent::Reference, + [index, title, url] +); + +impl_java_class!( + "com/longbridge/agent/QuestionOption", + longbridge::agent::QuestionOption, + [description] +); + +impl_java_class!( + "com/longbridge/agent/Question", + longbridge::agent::Question, + [ + question, + #[java(objarray)] + options, + multi_select + ] +); + +impl_java_class!( + "com/longbridge/agent/Interrupt", + longbridge::agent::Interrupt, + [ + node_id, + tool_call_id, + #[java(objarray)] + questions, + message_id, + chat_id + ] +); + +// The Java class is named `ConversationError` (not `AgentError`) since it +// describes a failed conversation *run*, not a JNI/transport-level failure — +// `OpenApiException` already owns that role for this SDK. `impl_java_class!` +// only cares that the two names are given independently, so no wrapper type +// is needed just to rename it. +impl_java_class!( + "com/longbridge/agent/ConversationError", + longbridge::agent::AgentError, + [code, message] +); + +impl_java_class!( + "com/longbridge/agent/ChatStartedEvent", + longbridge::agent::ChatStartedPayload, + [chat_uid, message_id] +); + +// JNI-side view of `longbridge::agent::WorkflowStartedInputs`, the `inputs` +// sub-object of a `workflow_started` event — mapped to its own Java class +// the same way `Interrupt`/`Question` nest inside `ConversationResponse` +// above. +impl_java_class!( + "com/longbridge/agent/WorkflowStartedInputs", + longbridge::agent::WorkflowStartedInputs, + [chat_id, chat_uid, message_id, query] +); + +impl_java_class!( + "com/longbridge/agent/WorkflowStartedEvent", + longbridge::agent::WorkflowStartedPayload, + [hit_cache, inputs, started_at, workflow_id] +); + +impl_java_class!( + "com/longbridge/agent/MessageEvent", + longbridge::agent::MessagePayload, + [ + text, + message_type, + key, + started_at, + stage, + stage_title, + stage_finished_title, + outputs + ] +); + +/// JNI-side marker for +/// [`longbridge::agent::ConversationStreamEvent::Ping`], which — unlike +/// every other variant — carries no payload at all. `impl_java_class!` works +/// fine against a zero-field struct, so this is used instead of hand-writing +/// a bespoke JNI object constructor for the one variant with nothing to +/// carry. See the manual `IntoJValue` for `ConversationStreamEvent` below, +/// the only place this type is used. +pub(crate) struct PingEvent {} + +impl_java_class!("com/longbridge/agent/PingEvent", PingEvent, []); + +impl_java_class!( + "com/longbridge/agent/ThinkingStartedEvent", + longbridge::agent::ThinkingStartedPayload, + [started_at] +); + +impl_java_class!( + "com/longbridge/agent/ThinkingFinishedEvent", + longbridge::agent::ThinkingFinishedPayload, + [finished_at, elapsed_time] +); + +impl_java_class!( + "com/longbridge/agent/NodeToolUseStartedEvent", + longbridge::agent::NodeToolUseStartedPayload, + [ + tool_use_id, + tool_name, + tool_func_name, + tool_args, + tips, + #[java(objarray)] + tip_chips, + iteration, + started_at + ] +); + +/// JNI-side view of [`longbridge::agent::NodeToolUseOutputs`], with +/// `references`/`reference_domains` normalized from `Option>` down to a +/// plain `Vec` (empty when absent) — same convention as +/// [`ConversationResponse`]'s `references` field above. +pub(crate) struct NodeToolUseOutputs { + pub(crate) references: Vec, + pub(crate) reference_domains: Vec, + pub(crate) query: Option, + pub(crate) text: Option, + pub(crate) tool_args: Option, + pub(crate) data: Option, +} + +impl From for NodeToolUseOutputs { + fn from(value: longbridge::agent::NodeToolUseOutputs) -> Self { + Self { + references: value.references.unwrap_or_default(), + reference_domains: value.reference_domains.unwrap_or_default(), + query: value.query, + text: value.text, + tool_args: value.tool_args, + data: value.data, + } + } +} + +impl_java_class!( + "com/longbridge/agent/NodeToolUseOutputs", + NodeToolUseOutputs, + [ + #[java(objarray)] + references, + #[java(objarray)] + reference_domains, + query, + text, + tool_args, + data + ] +); + +impl_java_class!( + "com/longbridge/agent/NodeToolUseFinishedEvent", + longbridge::agent::NodeToolUseFinishedPayload, + [ + tool_use_id, + status, + error, + elapsed_time, + started_at, + tool_name, + tool_func_name, + tool_args, + tool_type, + tips, + #[java(objarray)] + tip_chips, + iteration, + is_thinking, + #[java(set_as = crate::types::NodeToolUseOutputs)] + outputs + ] +); + +impl_java_class!( + "com/longbridge/agent/SubagentStartedEvent", + longbridge::agent::SubagentStartedPayload, + [ + node_id, + tool_use_id, + started_at, + goal, + prompt, + subagent_id, + #[java(objarray)] + tools + ] +); + +impl_java_class!( + "com/longbridge/agent/SubagentProgressEvent", + longbridge::agent::SubagentProgressPayload, + [ + node_id, + parent_tool_call_id, + subagent_tool_name, + subagent_tool_args, + subagent_status, + subagent_duration_ms, + subagent_iteration, + started_at + ] +); + +/// JNI-side view of [`longbridge::agent::SubagentOutputs`], with +/// `subagent_tools` normalized from `Option>` down to a plain `Vec` +/// (empty when absent) — same convention as [`NodeToolUseOutputs`] above. +pub(crate) struct SubagentOutputs { + pub(crate) goal: Option, + pub(crate) result: Option, + pub(crate) subagent_tools: Vec, +} + +impl From for SubagentOutputs { + fn from(value: longbridge::agent::SubagentOutputs) -> Self { + Self { + goal: value.goal, + result: value.result, + subagent_tools: value.subagent_tools.unwrap_or_default(), + } + } +} + +impl_java_class!( + "com/longbridge/agent/SubagentOutputs", + SubagentOutputs, + [ + goal, + result, + #[java(objarray)] + subagent_tools + ] +); + +impl_java_class!( + "com/longbridge/agent/SubagentFinishedEvent", + longbridge::agent::SubagentFinishedPayload, + [ + node_id, + tool_use_id, + status, + started_at, + elapsed_time, + error, + #[java(set_as = crate::types::SubagentOutputs)] + outputs + ] +); + +impl_java_class!( + "com/longbridge/agent/AgentToolStartedEvent", + longbridge::agent::AgentToolStartedPayload, + [ + node_id, + tool_use_id, + agent_tool_name, + title, + started_at, + tool_args, + tool_name, + tips, + #[java(objarray)] + tip_chips, + is_thinking + ] +); + +impl_java_class!( + "com/longbridge/agent/AgentToolProgressEvent", + longbridge::agent::AgentToolProgressPayload, + [ + node_id, + parent_tool_call_id, + agent_tool_name, + inner_tool_name, + inner_tool_args, + status, + duration_ms, + started_at, + is_thinking + ] +); + +impl_java_class!( + "com/longbridge/agent/AgentToolFinishedEvent", + longbridge::agent::AgentToolFinishedPayload, + [ + node_id, + tool_use_id, + agent_tool_name, + status, + started_at, + elapsed_time, + error, + tool_args, + outputs, + tool_type, + tips, + #[java(objarray)] + tip_chips, + is_thinking + ] +); + +/// JNI-side wrapper so that +/// [`longbridge::agent::ConversationStreamEvent::HumanInteractionRequired`] +/// can go through the same `impl_java_class!` machinery as every other event +/// payload, wrapping the same [`ConversationResponse`] that +/// [`WorkflowFinishedEvent`] wraps — see the manual `IntoJValue` for +/// `ConversationStreamEvent` below, which is the only place this type is +/// used. +pub(crate) struct HumanInteractionRequiredEvent { + pub(crate) response: ConversationResponse, +} + +impl_java_class!( + "com/longbridge/agent/HumanInteractionRequiredEvent", + HumanInteractionRequiredEvent, + [response] +); + +impl_java_class!( + "com/longbridge/agent/QueryMaskedEvent", + longbridge::agent::QueryMaskedPayload, + [raw_query, masked_query] +); + +impl_java_class!( + "com/longbridge/agent/PlanChangedEvent", + longbridge::agent::PlanChangedPayload, + [node_id, started_at, outputs, tool_name] +); + +impl_java_class!( + "com/longbridge/agent/ContextCompressStartedEvent", + longbridge::agent::ContextCompressStartedPayload, + [started_at, inputs] +); + +impl_java_class!( + "com/longbridge/agent/ContextCompressFinishedEvent", + longbridge::agent::ContextCompressFinishedPayload, + [created_at, inputs, outputs] +); + +impl_java_class!( + "com/longbridge/agent/ChatFinishedEvent", + longbridge::agent::ChatFinishedPayload, + [chat_id, chat_uid, message_id, error, error_message] +); + +/// JNI-side view of [`longbridge::agent::ConversationResponse`], with +/// `references` normalized from `Option>` down to a plain +/// `Vec` (empty when absent) so it can use the same `#[java(objarray)]` +/// convention as every other list field — mirrors how `StockPosition` above +/// collapses `Option`/`Option` fields with `unwrap_or_default`. +pub(crate) struct ConversationResponse { + pub(crate) chat_uid: String, + pub(crate) message_id: String, + pub(crate) status: longbridge::agent::ConversationStatus, + pub(crate) answer: String, + pub(crate) references: Vec, + pub(crate) elapsed_time: f64, + pub(crate) interrupt: Option, + pub(crate) error: Option, +} + +impl From for ConversationResponse { + fn from(value: longbridge::agent::ConversationResponse) -> Self { + Self { + chat_uid: value.chat_uid, + message_id: value.message_id, + status: value.status, + answer: value.answer, + references: value.references.unwrap_or_default(), + elapsed_time: value.elapsed_time, + interrupt: value.interrupt, + error: value.error, + } + } +} + +impl_java_class!( + "com/longbridge/agent/ConversationResponse", + ConversationResponse, + [ + chat_uid, + message_id, + status, + answer, + #[java(objarray)] + references, + elapsed_time, + interrupt, + error + ] +); + +/// JNI-side wrapper so that +/// [`longbridge::agent::ConversationStreamEvent::WorkflowFinished`] can go +/// through the same `impl_java_class!` machinery as every other event +/// payload — see the manual `IntoJValue` for `ConversationStreamEvent` below, +/// which is the only place this type is used. +pub(crate) struct WorkflowFinishedEvent { + pub(crate) response: ConversationResponse, +} + +impl_java_class!( + "com/longbridge/agent/WorkflowFinishedEvent", + WorkflowFinishedEvent, + [response] +); + +impl_java_class!( + "com/longbridge/agent/ChatTitleUpdatedEvent", + longbridge::agent::ChatTitleUpdatedPayload, + [chat_id, chat_uid, source, title, updated_at] +); + +/// JNI-side wrapper for +/// [`longbridge::agent::ConversationStreamEvent::Other`], carrying the SSE +/// envelope's `event` type name alongside the raw event JSON as a string +/// (same convention as `AlertItem.valueMap`). +pub(crate) struct OtherEvent { + pub(crate) event: String, + pub(crate) json: serde_json::Value, +} + +impl_java_class!("com/longbridge/agent/OtherEvent", OtherEvent, [event, json]); + +/// `ConversationStreamEvent` is an enum-with-payload, so unlike every type +/// above it can't go through `impl_java_class!` (which only knows how to +/// build a single Java class by reflecting named struct fields onto it). +/// Instead each variant is modeled as its own Java subclass of the +/// `ConversationStreamEvent` sealed-style hierarchy +/// (`ChatStartedEvent`/`WorkflowStartedEvent`/`MessageEvent`/`PingEvent`/ +/// `ThinkingStartedEvent`/`ThinkingFinishedEvent`/`NodeToolUseStartedEvent`/ +/// `NodeToolUseFinishedEvent`/`SubagentStartedEvent`/`SubagentProgressEvent`/ +/// `SubagentFinishedEvent`/`AgentToolStartedEvent`/`AgentToolProgressEvent`/ +/// `AgentToolFinishedEvent`/`HumanInteractionRequiredEvent`/ +/// `QueryMaskedEvent`/`PlanChangedEvent`/`ContextCompressStartedEvent`/ +/// `ContextCompressFinishedEvent`/`ChatFinishedEvent`/`WorkflowFinishedEvent`/ +/// `ChatTitleUpdatedEvent`/`OtherEvent`), and this manual `IntoJValue` picks +/// the right one — the resulting object reference is passed to +/// `Flow.Subscriber.onNext(Object)` as-is (generics are erased on the Java +/// side, so any subclass of the sealed base is a valid argument there). +impl crate::types::IntoJValue for longbridge::agent::ConversationStreamEvent { + fn into_jvalue<'a>( + self, + env: &mut jni::JNIEnv<'a>, + ) -> jni::errors::Result> { + use longbridge::agent::ConversationStreamEvent::*; + match self { + ChatStarted(payload) => payload.into_jvalue(env), + WorkflowStarted(payload) => payload.into_jvalue(env), + Message(payload) => payload.into_jvalue(env), + Ping => PingEvent {}.into_jvalue(env), + ThinkingStarted(payload) => payload.into_jvalue(env), + ThinkingFinished(payload) => payload.into_jvalue(env), + NodeToolUseStarted(payload) => payload.into_jvalue(env), + NodeToolUseFinished(payload) => payload.into_jvalue(env), + SubagentStarted(payload) => payload.into_jvalue(env), + SubagentProgress(payload) => payload.into_jvalue(env), + SubagentFinished(payload) => payload.into_jvalue(env), + AgentToolStarted(payload) => payload.into_jvalue(env), + AgentToolProgress(payload) => payload.into_jvalue(env), + AgentToolFinished(payload) => payload.into_jvalue(env), + HumanInteractionRequired(resp) => HumanInteractionRequiredEvent { + response: ConversationResponse::from(resp), + } + .into_jvalue(env), + QueryMasked(payload) => payload.into_jvalue(env), + PlanChanged(payload) => payload.into_jvalue(env), + ContextCompressStarted(payload) => payload.into_jvalue(env), + ContextCompressFinished(payload) => payload.into_jvalue(env), + ChatFinished(payload) => payload.into_jvalue(env), + WorkflowFinished(resp) => WorkflowFinishedEvent { + response: ConversationResponse::from(resp), + } + .into_jvalue(env), + ChatTitleUpdated(payload) => payload.into_jvalue(env), + Other { event, data } => OtherEvent { event, json: data }.into_jvalue(env), + } + } +} diff --git a/java/src/types/enum_types.rs b/java/src/types/enum_types.rs index d242600d06..686bf6b809 100644 --- a/java/src/types/enum_types.rs +++ b/java/src/types/enum_types.rs @@ -515,3 +515,8 @@ impl_java_enum!( NoOpinion ] ); +impl_java_enum!( + "com/longbridge/agent/ConversationStatus", + longbridge::agent::ConversationStatus, + [Succeeded, Interrupted, Failed, Stopped] +); diff --git a/java/src/types/mod.rs b/java/src/types/mod.rs index b8f968f5bf..94956e7dc5 100644 --- a/java/src/types/mod.rs +++ b/java/src/types/mod.rs @@ -20,8 +20,9 @@ use jni::{ pub(crate) use self::{ classes::{ - CreateWatchlistGroupResponse, SecurityCalcIndex, StockPosition, StockPositionChannel, - StockPositionsResponse, + ConversationResponse, CreateWatchlistGroupResponse, HumanInteractionRequiredEvent, + NodeToolUseOutputs, OtherEvent, PingEvent, SecurityCalcIndex, StockPosition, + StockPositionChannel, StockPositionsResponse, SubagentOutputs, WorkflowFinishedEvent, }, object_array::ObjectArray, primary_array::PrimaryArray, diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 328373f615..fed6c9271c 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -30,6 +30,101 @@ export declare class AccountBalance { get frozenTransactionFees(): Array } +/** + * AI Agent conversation context. + * + * Reference: + */ +export declare class AgentContext { + /** Create a new AgentContext. */ + static new(config: Config): AgentContext + /** + * List the Workspaces the current account belongs to. + * + * #### Example + * + * ```javascript + * const { Config, AgentContext } = require('longbridge'); + * + * const ctx = AgentContext.new(config); + * const resp = await ctx.workspaces(); + * console.log(resp); + * ``` + */ + workspaces(): Promise + /** + * List the Agents in the specified Workspace. + * + * `page`/`limit` control pagination; `name` fuzzy-searches by Agent name. + * All three are optional. + * + * #### Example + * + * ```javascript + * const { Config, AgentContext } = require('longbridge'); + * + * const ctx = AgentContext.new(config); + * const resp = await ctx.agents(workspaceId); + * console.log(resp); + * ``` + */ + agents(workspaceId: string, page?: number | undefined | null, limit?: number | undefined | null, name?: string | undefined | null): Promise + /** + * Start a conversation with the specified Agent, blocking until the run + * succeeds, is interrupted, or fails. + * + * #### Example + * + * ```javascript + * const { Config, AgentContext } = require('longbridge'); + * + * const ctx = AgentContext.new(config); + * const resp = await ctx.conversation(agentId, "How has Tesla stock performed recently?"); + * console.log(resp); + * ``` + */ + conversation(agentId: string, query: string, chatUid?: string | undefined | null): Promise + /** + * Resume an interrupted conversation, blocking until the run succeeds, is + * interrupted again, or fails. + * + * `answersByToolCall` is keyed by `toolCallId` (see `Interrupt`), each + * value being a map of question text to answer. + */ + continueConversation(agentId: string, chatUid: string, messageId: string, answersByToolCall: Record>): Promise + /** + * Start a conversation with the specified Agent, invoking `callback` for + * every progress event observed over SSE, and resolving to the final + * `ConversationResponse` once the run finishes (this is the same shape + * `conversation` returns). + * + * #### Example + * + * ```javascript + * const { Config, AgentContext } = require('longbridge'); + * + * const ctx = AgentContext.new(config); + * const resp = await ctx.conversationStreamed( + * agentId, + * "How has Tesla stock performed recently?", + * undefined, + * (err, event) => console.log(event), + * ); + * console.log(resp); + * ``` + */ + conversationStreamed(agentId: string, query: string, chatUid: string | undefined | null, callback: (err: null | Error, event: ConversationStreamEvent) => void): Promise + /** + * Resume an interrupted conversation, invoking `callback` for every + * progress event observed over SSE, and resolving to the final + * `ConversationResponse` once the run finishes. + * + * `answersByToolCall` is keyed by `toolCallId` (see `Interrupt`), each + * value being a map of question text to answer. + */ + continueConversationStreamed(agentId: string, chatUid: string, messageId: string, answersByToolCall: Record>, callback: (err: null | Error, event: ConversationStreamEvent) => void): Promise +} + /** Price alert management context. */ export declare class AlertContext { /** Create a new AlertContext. */ @@ -632,23 +727,23 @@ export declare class FundamentalContext { macroeconomicIndicators(country?: MacroeconomicCountry | undefined | null, keyword?: string | undefined | null, offset?: number | undefined | null, limit?: number | undefined | null): Promise /** Get historical data for a macroeconomic indicator */ macroeconomic(indicatorCode: string, startDate?: string | undefined | null, endDate?: string | undefined | null, offset?: number | undefined | null, limit?: number | undefined | null): Promise - /** Get US company overview. US token required. counterID format: "ST/US/AAPL" */ + /** Get US company overview. US token required. */ usCompanyOverview(symbol: string): Promise - /** Get US valuation snapshot (PE/PB/PS). US token required. */ + /** Get US valuation overview. US token required. */ usValuationOverview(symbol: string): Promise - /** Get US financial overview (revenue/net income/EPS). Returns JSON string. US token required. */ + /** Get US financial overview. US token required. */ usFinancialOverview(symbol: string, report: string): Promise - /** Get US financial statement. kind: "IS"|"BS"|"CF". report: "q1"|"qf"|"saf"|"3q"|"af". US token required. */ + /** Get US financial statement v3. kind: "IS"/"BS"/"CF". US token required. */ usFinancialStatement(symbol: string, kind: string, report: string): Promise - /** Get US key financial metrics. report: "q1"|"qf"|"saf"|"3q"|"af". US token required. */ + /** Get US key financial metrics. US token required. */ usKeyFinancialMetrics(symbol: string, report: string): Promise - /** Get US analyst consensus estimates. report: "q1"|"qf"|"saf"|"3q"|"af". US token required. */ + /** Get US analyst consensus estimates. US token required. */ usAnalystConsensus(symbol: string, report: string): Promise /** Get US ETF dividend history. US token required. */ usEtfDividendInfo(symbol: string): Promise - /** Get US company historical dividends. US token required. */ + /** Get US company dividends. US token required. */ usCompanyDividends(symbol: string): Promise - /** Get US ETF document list. size=null returns all. US token required. */ + /** Get US ETF document list. size=None returns all. US token required. */ usEtfFiles(symbol: string, size?: number | undefined | null): Promise } @@ -2058,7 +2153,7 @@ export declare class QuoteContext { optionVolume(symbol: string): Promise /** Get daily historical option volume */ optionVolumeDaily(symbol: string, timestamp: number, count: number): Promise - /** Get US cryptocurrency market overview. counterID format: "CY/US/BTC". US token required. */ + /** Get US cryptocurrency market overview. US token required. */ usCryptoOverview(symbol: string): Promise } @@ -2812,6 +2907,17 @@ export declare class TradeContext { * ``` */ orderDetail(orderId: string): Promise + /** + * Query US order list. Returns JSON string. US token required. + * symbol: user-facing symbol e.g. "AAPL.US"; action: 0=all/1=buy/2=sell. + */ + usQueryOrders(symbol: string | undefined | null, action: number, startAt: number, endAt: number, queryType: number, page: number, limit: number): Promise + /** Get US order detail. US token required. */ + usOrderDetail(orderId: string): Promise + /** Get US account asset overview. US token required. */ + usAssetOverview(): Promise + /** Get US realized P&L. US token required. */ + usRealizedPl(currency: string, category?: string | undefined | null): Promise /** * Estimating the maximum purchase quantity for Hong Kong and US stocks, * warrants, and options @@ -2832,21 +2938,6 @@ export declare class TradeContext { * ``` */ estimateMaxPurchaseQuantity(opts: EstimateMaxPurchaseQuantityOptions): Promise - /** Query US order list (paginated). Returns JSON string. US token required. */ - /** - * Query US order list. Returns JSON string with shape `{orders: USOrder[], total_count: number}`. - * symbol: user-facing symbol e.g. "AAPL.US" (optional). - * action: 0=all, 1=buy, 2=sell. - * queryType: 0=all (incl. Rejected), 1=pending, 2=history (filled only). - * US token required. - */ - usQueryOrders(symbol?: string | null, action?: number, startAt?: number, endAt?: number, queryType?: number, page?: number, limit?: number): Promise - /** Get US order detail. isAttached=true includes take-profit/stop-loss sub-orders. Returns JSON string. US token required. */ - usOrderDetail(orderId: string): Promise - /** Get US account asset overview (stocks/options/crypto/buy power). US token required. */ - usAssetOverview(): Promise - /** Get US realized P&L. category: "ALL"|"STOCK"|"OPTION"|"CRYPTO". US token required. */ - usRealizedPl(currency: string, category?: string | undefined | null): Promise } /** The information of trading session */ @@ -3005,6 +3096,127 @@ export declare const enum AdjustType { ForwardAdjust = 1 } +/** An Agent in a Workspace */ +export interface Agent { + /** Agent UID, used as the path parameter of `AgentContext.conversation` */ + uid: string + /** Agent name */ + name: string + /** Agent description */ + description: string + /** Agent mode, e.g. `chat` */ + mode: string + /** Icon URL */ + icon: string + /** Whether published; only published Agents can start conversations */ + isPublished: boolean + /** Publish time, Unix timestamp in seconds; 0 if unpublished */ + publishedAt: number + /** Creation time, Unix timestamp in seconds */ + createdAt: number + /** Last updated time, Unix timestamp in seconds */ + updatedAt: number +} + +/** Present when a conversation run failed */ +export interface AgentError { + /** Error code */ + code: number + /** Error message */ + message: string +} + +/** Response for `AgentContext.agents` */ +export interface AgentsResponse { + /** Agent list */ + agents: Array + /** Total number of matching Agents */ + total: number +} + +/** Payload of an `agent_tool_finished` stream event */ +export interface AgentToolFinishedPayload { + /** ID of the calling node */ + nodeId: string + /** Matches the `toolUseId` of `AgentToolStarted` */ + toolUseId: string + /** Identifier of the Agent being called */ + agentToolName: string + /** `succeeded` / `failed` */ + status: string + /** Start time, Unix timestamp in seconds */ + startedAt: number + /** Total duration in seconds */ + elapsedTime: number + /** Error description on failure */ + error: string + /** Call arguments as a JSON string */ + toolArgs: string + /** Result of the delegated Agent */ + outputs?: any + /** Tool category */ + toolType: string + /** Progress text; may be omitted */ + tips: string + /** Short tags; may be omitted */ + tipChips: Array + /** `true` if during the thinking phase */ + isThinking: boolean +} + +/** + * Payload of an `agent_tool_progress` stream event, emitted for each inner + * tool call the delegated Agent makes. + */ +export interface AgentToolProgressPayload { + /** ID of the calling node */ + nodeId: string + /** `toolUseId` of the owning `AgentToolStarted` event */ + parentToolCallId: string + /** Identifier of the Agent being called */ + agentToolName: string + /** Name of the inner tool the delegated Agent called */ + innerToolName: string + /** Arguments of that inner call, as a JSON string */ + innerToolArgs: string + /** Status of the inner call: `running` / `succeeded` / `failed` */ + status: string + /** Duration of the inner call in milliseconds */ + durationMs: number + /** Start time, Unix timestamp in seconds */ + startedAt: number + /** `true` if during the thinking phase */ + isThinking: boolean +} + +/** + * Payload of an `agent_tool_started` stream event. When the Agent delegates + * to another Agent as a tool, that inner run is reported with the + * `agentTool*` family — the shape mirrors the subagent events. + */ +export interface AgentToolStartedPayload { + /** ID of the calling node */ + nodeId: string + /** Unique ID of this call; matches the finished event */ + toolUseId: string + /** Identifier of the Agent being called */ + agentToolName: string + /** Display title; may be omitted */ + title: string + /** Start time, Unix timestamp in seconds */ + startedAt: number + /** Call arguments as a JSON string */ + toolArgs: string + /** Localized display name */ + toolName: string + /** Progress text; may be omitted */ + tips: string + /** Short tags; may be omitted */ + tipChips: Array + /** `true` if called during the thinking phase */ + isThinking: boolean +} + /** A/H premium intraday response */ export interface AhPremiumIntraday { /** Intraday data points */ @@ -3506,6 +3718,49 @@ export declare const enum ChargeCategoryCode { Third = 2 } +/** + * Payload of a `chat_finished` stream event, observed once all `message` + * events for this round have been sent, shortly before `workflow_finished` + */ +export interface ChatFinishedPayload { + /** ID of the owning conversation */ + chatId: number + /** Conversation identifier */ + chatUid: string + /** Message ID of this round */ + messageId: string + /** Empty string in every run observed so far */ + error: string + /** Empty string in every run observed so far */ + errorMessage: string +} + +/** Payload of a `chat_started` stream event */ +export interface ChatStartedPayload { + /** Conversation identifier */ + chatUid: string + /** Message ID of this round */ + messageId: string +} + +/** + * Payload of a `chat_title_updated` stream event — the server auto-generates + * a short title for the conversation as a UI convenience. Can arrive before + * *or* after `workflow_finished`; not tied to the run's outcome. + */ +export interface ChatTitleUpdatedPayload { + /** ID of the owning conversation */ + chatId: number + /** Conversation identifier */ + chatUid: string + /** Where the title came from, e.g. `"ai_generated"` */ + source: string + /** The new (possibly truncated) title */ + title: string + /** Unix timestamp in seconds */ + updatedAt: number +} + /** Commission-free Status */ export declare const enum CommissionFreeStatus { /** Unknown */ @@ -3656,6 +3911,179 @@ export interface ConstituentStock { tradeStatus: number } +/** + * Payload of a `context_compress_finished` stream event. Unlike other + * events, the timestamp here is an RFC 3339 string. + */ +export interface ContextCompressFinishedPayload { + /** Finish time, RFC 3339 */ + createdAt: string + /** Compression input summary */ + inputs?: any + /** Compression result summary */ + outputs?: any +} + +/** + * Payload of a `context_compress_started` stream event, marking the start + * of a context-compression pass triggered by a long conversation. Unlike + * other events, the timestamp here is an RFC 3339 string. + */ +export interface ContextCompressStartedPayload { + /** Start time, RFC 3339 */ + startedAt: string + /** Compression input summary */ + inputs?: any +} + +/** + * Response for `AgentContext.conversation`, + * `AgentContext.continueConversation`, and the final result of the streamed + * counterparts + */ +export interface ConversationResponse { + /** + * Conversation identifier, used for follow-up questions and + * troubleshooting + */ + chatUid: string + /** Message ID of this round */ + messageId: string + /** Final run status */ + status: ConversationStatus + /** Final answer text; valid when `status` is `succeeded` */ + answer: string + /** Sources referenced by the answer */ + references?: Array + /** Run duration in seconds */ + elapsedTime: number + /** Present only when `status` is `interrupted` */ + interrupt?: Interrupt + /** Present only when the run failed */ + error?: AgentError +} + +/** Final run status of a conversation */ +export declare const enum ConversationStatus { + /** The run completed successfully */ + Succeeded = 0, + /** The run is paused, waiting for `AgentContext.continueConversation` */ + Interrupted = 1, + /** The run failed */ + Failed = 2, + /** The run was stopped */ + Stopped = 3 +} + +/** + * One event observed while streaming `AgentContext.conversationStreamed` or + * `AgentContext.continueConversationStreamed`. + * + * Design note: the Rust core models this as an enum with a per-variant + * payload (`longbridge::agent::ConversationStreamEvent`), but napi-rs has no + * ergonomic equivalent of a Rust/Serde "enum with data" for a plain + * `#[napi(object)]` value, and there's no existing precedent for reifying one + * as a single JS value in this codebase (the closest analogue, + * `trade::PushEvent`, is dispatched to separate per-variant JS callbacks + * instead). We instead mirror the common "discriminant + optional per-kind + * fields" shape used for tagged unions in plain JS/JSON: `kind` is one of + * `"chat_started" | "workflow_started" | "message" | "ping" | + * "thinking_started" | "thinking_finished" | "node_tool_use_started" | + * "node_tool_use_finished" | "subagent_started" | "subagent_progress" | + * "subagent_finished" | "agent_tool_started" | "agent_tool_progress" | + * "agent_tool_finished" | "human_interaction_required" | "query_masked" | + * "plan_changed" | "context_compress_started" | "context_compress_finished" | + * "chat_finished" | "workflow_finished" | "chat_title_updated" | "other"`, + * and exactly one of `chatStarted` / `workflowStarted` / `message` / + * `thinkingStarted` / `thinkingFinished` / `nodeToolUseStarted` / + * `nodeToolUseFinished` / `subagentStarted` / `subagentProgress` / + * `subagentFinished` / `agentToolStarted` / `agentToolProgress` / + * `agentToolFinished` / `humanInteractionRequired` / `queryMasked` / + * `planChanged` / `contextCompressStarted` / `contextCompressFinished` / + * `chatFinished` / `workflowFinished` / `chatTitleUpdated` / `other` is set, + * matching `kind` — except `"ping"`, a heartbeat with no payload, for which + * every payload field is `None`. When `kind` is `"other"`, `otherEvent` + * additionally carries the SSE envelope's `event` field (the event type + * name). + */ +export interface ConversationStreamEvent { + /** + * Discriminant: one of `"chat_started"`, `"workflow_started"`, + * `"message"`, `"ping"`, `"thinking_started"`, `"thinking_finished"`, + * `"node_tool_use_started"`, `"node_tool_use_finished"`, + * `"subagent_started"`, `"subagent_progress"`, `"subagent_finished"`, + * `"agent_tool_started"`, `"agent_tool_progress"`, + * `"agent_tool_finished"`, `"human_interaction_required"`, + * `"query_masked"`, `"plan_changed"`, `"context_compress_started"`, + * `"context_compress_finished"`, `"chat_finished"`, + * `"workflow_finished"`, `"chat_title_updated"`, or `"other"` + */ + kind: string + /** Set when `kind` is `"chat_started"` */ + chatStarted?: ChatStartedPayload + /** Set when `kind` is `"workflow_started"` */ + workflowStarted?: WorkflowStartedPayload + /** Set when `kind` is `"message"` */ + message?: MessagePayload + /** Set when `kind` is `"thinking_started"` */ + thinkingStarted?: ThinkingStartedPayload + /** Set when `kind` is `"thinking_finished"` */ + thinkingFinished?: ThinkingFinishedPayload + /** Set when `kind` is `"node_tool_use_started"` */ + nodeToolUseStarted?: NodeToolUseStartedPayload + /** Set when `kind` is `"node_tool_use_finished"` */ + nodeToolUseFinished?: NodeToolUseFinishedPayload + /** Set when `kind` is `"subagent_started"` */ + subagentStarted?: SubagentStartedPayload + /** Set when `kind` is `"subagent_progress"` */ + subagentProgress?: SubagentProgressPayload + /** Set when `kind` is `"subagent_finished"` */ + subagentFinished?: SubagentFinishedPayload + /** Set when `kind` is `"agent_tool_started"` */ + agentToolStarted?: AgentToolStartedPayload + /** Set when `kind` is `"agent_tool_progress"` */ + agentToolProgress?: AgentToolProgressPayload + /** Set when `kind` is `"agent_tool_finished"` */ + agentToolFinished?: AgentToolFinishedPayload + /** + * Set when `kind` is `"human_interaction_required"`, carrying the run's + * outcome for an interrupted run — the same `ConversationResponse` shape + * `workflowFinished` carries for the other outcomes. Unlike + * `workflowFinished`, this is set instead of (never alongside) + * `workflowFinished` for the same run. + */ + humanInteractionRequired?: ConversationResponse + /** Set when `kind` is `"query_masked"` */ + queryMasked?: QueryMaskedPayload + /** Set when `kind` is `"plan_changed"` */ + planChanged?: PlanChangedPayload + /** Set when `kind` is `"context_compress_started"` */ + contextCompressStarted?: ContextCompressStartedPayload + /** Set when `kind` is `"context_compress_finished"` */ + contextCompressFinished?: ContextCompressFinishedPayload + /** Set when `kind` is `"chat_finished"` */ + chatFinished?: ChatFinishedPayload + /** + * Set when `kind` is `"workflow_finished"`, carrying the run's outcome + * — not necessarily the last event of the stream, since the server may + * still emit a few more housekeeping events (`kind` `"other"`) before + * actually closing the connection + */ + workflowFinished?: ConversationResponse + /** Set when `kind` is `"chat_title_updated"` */ + chatTitleUpdated?: ChatTitleUpdatedPayload + /** + * Set when `kind` is `"other"` — the SSE envelope's `event` field (the + * event type name) + */ + otherEvent?: string + /** + * Set when `kind` is `"other"` — raw JSON of an event type not + * recognized by this SDK version + */ + other?: any +} + /** One corporate action event */ export interface CorpActionItem { /** Internal ID */ @@ -4023,6 +4451,19 @@ export interface ExtraConfigParams { enablePrintQuotePackages?: boolean /** Set the path of the log files (Default: `no logs`) */ logPath?: string + /** + * Enable paper trading mode (default: `false`). + * + * When `true`, all API calls target the paper trading (simulation) + * environment. The server validates the token: if it belongs to a + * real-money account the server returns an error. + * + * When `false` (the default) the server imposes no restrictions — both + * paper trading and real-money accounts are accepted. + * + * Paper trading users should set this to `true` as a safety guard. + */ + enablePapertrading?: boolean } /** Filter warrant expiry date type */ @@ -4499,6 +4940,23 @@ export declare const enum InstitutionRecommend { NoOpinion = 7 } +/** + * Present when a conversation run is interrupted, waiting for + * `AgentContext.continueConversation` + */ +export interface Interrupt { + /** ID of the node that triggered the interrupt */ + nodeId: string + /** Tool call ID of this inquiry; used as the answer key when continuing */ + toolCallId: string + /** Questions you need to answer */ + questions: Array + /** ID of the paused message */ + messageId: number + /** ID of the owning conversation */ + chatId: number +} + /** Investor relations response */ export interface InvestRelations { /** Link to IR page */ @@ -4639,6 +5097,43 @@ export interface MarketTimeItem { delaySubStatus: number } +/** + * Payload of a `message` stream event — an incremental text chunk. This is + * the highest-frequency event; concatenate `text` fragments in arrival + * order. + */ +export interface MessagePayload { + /** Incremental text fragment */ + text: string + /** + * `answer` — final answer text; `think` — reasoning process; `process` + * — stage progress description + */ + messageType: string + /** + * Identifier of the stream segment this fragment belongs to. Fragments + * with the same `key` form one continuous block — group by `key` when + * rendering + */ + key: string + /** Time this segment started, Unix timestamp in seconds */ + startedAt: number + /** Stage identifier; only present when `messageType` is `"process"` */ + stage: string + /** + * Stage title while running; only present when `messageType` is + * `"process"` + */ + stageTitle: string + /** + * Stage title after it finishes; only present when `messageType` is + * `"process"` + */ + stageFinishedTitle: string + /** Extra payload attached to the fragment; usually absent */ + outputs?: any +} + /** Localized text in simplified Chinese, traditional Chinese, and English */ export interface MultiLanguageText { english: string @@ -4656,6 +5151,93 @@ export interface MyTopicsRequest { topicType?: string } +/** + * Payload of a `node_tool_use_finished` stream event — the tool call has + * ended. + */ +export interface NodeToolUseFinishedPayload { + /** Matches the `toolUseId` of the started event */ + toolUseId: string + /** `succeeded` / `failed` */ + status: string + /** Error description on failure */ + error: string + /** Call duration in seconds */ + elapsedTime: number + /** Start time, Unix timestamp in seconds */ + startedAt: number + /** Localized display name */ + toolName: string + /** Locale-stable tool identifier */ + toolFuncName: string + /** Call arguments as a JSON string */ + toolArgs: string + /** Tool category */ + toolType: string + /** Progress text */ + tips: string + /** Short tags; may be omitted */ + tipChips: Array + /** Round number */ + iteration: number + /** `true` if the call happened during the thinking phase */ + isThinking: boolean + /** Filtered call results, for display */ + outputs: NodeToolUseOutputs +} + +/** + * `outputs` of a `NodeToolUseFinishedPayload` — only carries fields meant + * for display + */ +export interface NodeToolUseOutputs { + /** Sources referenced by the tool result */ + references?: Array + /** Domains of the referenced sources */ + referenceDomains?: Array + /** The query the tool executed */ + query?: string + /** Raw response text of the tool */ + text?: string + /** Parsed request arguments */ + toolArgs?: any + /** Structured result; present only for selected tools */ + data?: any +} + +/** + * Payload of a `node_tool_use_started` stream event — an ordinary tool call + * has started. Match it to its `NodeToolUseFinished` counterpart by + * `toolUseId`. + */ +export interface NodeToolUseStartedPayload { + /** Unique ID of this call; matches the finished event */ + toolUseId: string + /** Localized display name of the tool */ + toolName: string + /** + * Locale-stable tool identifier; use this for logic keyed on the tool + * kind + */ + toolFuncName: string + /** Call arguments as a JSON string */ + toolArgs: string + /** + * Progress text suitable for direct display, e.g. `"Searching the + * web…"` + */ + tips: string + /** Short tags accompanying `tips`; may be omitted */ + tipChips: Array + /** + * Round number. Calls in the same round (same `iteration`) run in + * parallel + */ + iteration: number + /** Start time, Unix timestamp in seconds */ + startedAt: number +} + /** Key financial metrics from an operating report */ export interface OperatingFinancial { /** Ticker code */ @@ -4823,19 +5405,7 @@ export declare const enum OrderTag { /** Long term Order */ LongTerm = 2, /** Grey Order */ - Grey = 3, - /** Force Selling */ - MarginCall = 4, - /** OTC */ - Offline = 5, - /** Option Exercise Long */ - Creditor = 6, - /** Option Exercise Short */ - Debtor = 7, - /** Wavier Of Option Exercise */ - NonExercise = 8, - /** Trade Allocation */ - AllocatedSub = 9 + Grey = 3 } export declare const enum OrderType { @@ -4933,6 +5503,21 @@ export declare const enum PinnedMode { Remove = 1 } +/** + * Payload of a `plan_changed` stream event — the Agent created or updated + * its task plan. + */ +export interface PlanChangedPayload { + /** ID of the planning node */ + nodeId: string + /** Time of the change, Unix timestamp in seconds */ + startedAt: number + /** The current plan content */ + outputs?: any + /** Identifies the planning tool */ + toolName: string +} + /** One executive / board member */ export interface Professional { /** Internal wiki ID */ @@ -5184,6 +5769,34 @@ export declare const enum PushCandlestickMode { Confirmed = 1 } +/** + * Payload of a `query_masked` stream event — sensitive content in the user + * query was masked before processing. Display `maskedQuery` instead of the + * original query. + */ +export interface QueryMaskedPayload { + /** The original user query */ + rawQuery: string + /** The masked query */ + maskedQuery: string +} + +/** One question the Agent needs you to answer */ +export interface Question { + /** Question text */ + question: string + /** Options; empty means free-form answer */ + options: Array + /** Whether multiple options may be selected */ + multiSelect: boolean +} + +/** One option of a `Question` */ +export interface QuestionOption { + /** Option text */ + description: string +} + /** Rank categories response. `data` is a JSON string. */ export interface RankCategoriesResponse { /** Raw rank categories data (JSON string) */ @@ -5293,6 +5906,16 @@ export interface RecentBuybacks { netBuybackYieldTtm: string } +/** A source referenced by the answer */ +export interface Reference { + /** Reference index */ + index: number + /** Reference title */ + title: string + /** Reference URL */ + url: string +} + /** Options for replace order request */ export interface ReplaceOrderOptions { /** Order id */ @@ -5669,6 +6292,83 @@ export interface StockRatings { ratingsJson: string } +/** Payload of a `subagent_finished` stream event */ +export interface SubagentFinishedPayload { + /** ID of the node that spawned the subagent */ + nodeId: string + /** Matches the `toolUseId` of `SubagentStarted` */ + toolUseId: string + /** `succeeded` / `failed` */ + status: string + /** Start time, Unix timestamp in seconds */ + startedAt: number + /** Total subagent duration in seconds */ + elapsedTime: number + /** Error description on failure */ + error: string + /** + * Subagent result: `goal`, `result`, and the timeline of tool calls it + * made + */ + outputs: SubagentOutputs +} + +/** `outputs` of a `SubagentFinishedPayload` */ +export interface SubagentOutputs { + /** The goal that was assigned to the subagent */ + goal?: string + /** The subagent's result */ + result?: string + /** Timeline of tool calls the subagent made */ + subagentTools?: Array +} + +/** + * Payload of a `subagent_progress` stream event, emitted every time the + * subagent calls one of its own tools. Use it to render a live timeline + * inside the subagent card. + */ +export interface SubagentProgressPayload { + /** ID of the node that spawned the subagent */ + nodeId: string + /** `toolUseId` of the owning `SubagentStarted` event */ + parentToolCallId: string + /** Name of the tool the subagent called */ + subagentToolName: string + /** Arguments of that call, as a JSON string */ + subagentToolArgs: string + /** Status of that call: `running` / `succeeded` / `failed` */ + subagentStatus: string + /** Duration of that call in milliseconds */ + subagentDurationMs: number + /** The subagent's internal round number */ + subagentIteration: number + /** Start time, Unix timestamp in seconds */ + startedAt: number +} + +/** + * Payload of a `subagent_started` stream event. When the Agent spawns a + * subagent to work on a sub-task, the subagent's lifecycle is reported with + * this dedicated event family instead of `nodeToolUse*`. + */ +export interface SubagentStartedPayload { + /** ID of the node that spawned the subagent */ + nodeId: string + /** Unique ID of this spawn; matches the finished event */ + toolUseId: string + /** Start time, Unix timestamp in seconds */ + startedAt: number + /** Goal assigned to the subagent */ + goal: string + /** Full task prompt given to the subagent */ + prompt: string + /** Subagent identifier; may be omitted */ + subagentId: string + /** Tools granted to the subagent; may be omitted */ + tools: Array +} + /** Options for submit order request */ export interface SubmitOrderOptions { /** Security code */ @@ -5726,6 +6426,28 @@ export declare const enum SubType { Trade = 3 } +/** + * Payload of a `thinking_finished` stream event — the reasoning phase is + * over; answer text (`Message` with `messageType == "answer"`) follows. + */ +export interface ThinkingFinishedPayload { + /** Finish time, Unix timestamp in seconds */ + finishedAt: number + /** Reasoning duration in seconds */ + elapsedTime: number +} + +/** + * Payload of a `thinking_started` stream event — the Agent has entered the + * reasoning phase (analyzing the question, planning tool calls). Between + * this and `ThinkingFinished`, `Message` events with `messageType == + * "think"` and tool-call events may arrive. + */ +export interface ThinkingStartedPayload { + /** Start time, Unix timestamp in seconds */ + startedAt: number +} + /** Time in force type */ export declare const enum TimeInForceType { /** Unknown */ @@ -5909,275 +6631,308 @@ export interface UpdateWatchlistGroup { mode: SecuritiesUpdateMode } -/** One security's valuation comparison item. */ -export interface ValuationComparisonItem { - /** Symbol (e.g. `"AAPL.US"`) */ +/** AI chat context embedded in USAnalystConsensus. */ +export interface UsaiChatData { + agentId: string + handoffAgentId: string symbol: string - /** Security name */ - name: string - /** Currency */ - currency: string - /** Market capitalisation */ - marketValue: string - /** Latest closing price */ - priceClose: string - /** P/E ratio */ - pe: string - /** P/B ratio */ - pb: string - /** P/S ratio */ - ps: string - /** Return on equity */ - roe: string - /** Earnings per share */ - eps: string - /** Book value per share */ - bps: string - /** Dividends per share */ - dps: string - /** Dividend yield */ - divYld: string - /** Total assets */ - assets: string - /** Historical valuation points */ - history: Array + text: string + chatType: string + workflowType: string } -/** Valuation comparison response. */ -export interface ValuationComparisonResponse { - /** Valuation comparison items */ - list: Array +/** US analyst consensus estimates and AI analysis. */ +export interface UsAnalystConsensus { + aiSummary: string + aichatData: UsaiChatData + currency: string + report: string + list: Array + optReports: Array + h5Data: any } -/** Valuation metrics response */ -export interface ValuationData { - /** Valuation metrics */ - metrics: ValuationMetricsData +/** US account asset snapshot */ +export interface UsAssetOverview { + accountType: string + assetTimestamp: number + cashBuyPower: string + overnightBuyPower: string + currency: string + cashList: Array + stockList: Array + optionList: Array + cryptoList: Array + multiLeg: any } -/** Distribution statistics for one valuation metric */ -export interface ValuationDist { - /** Minimum value */ - low?: string - /** Maximum value */ - high?: string - /** Median value */ - median?: string - /** Current value */ - value?: string - /** Percentile ranking */ - ranking?: string - /** Ordinal rank index */ - rankIndex: string - /** Total securities in industry */ - rankTotal: string +/** One bracket/conditional sub-order attached to a main order. */ +export interface UsAttachedOrder { + attachedTypeDisplay: number + executedQty: string + quantity: string + status: string + triggerPrice: string + orderId: string + gtd: string + timeInForce: number + tag: number + activateOrderType: string + activateRth: number + submitPrice: string + symbol: string + withdrawn: boolean } -/** Historical valuation container */ -export interface ValuationHistoryData { - /** Historical metrics */ - metrics: ValuationHistoryMetrics +/** Action-button state for an order. */ +export interface UsButtonControl { + withdraw: number + replace: number + exceptionable: Array } -/** Historical data for one valuation metric */ -export interface ValuationHistoryMetric { - /** Description */ - desc: string - /** High */ - high?: string - /** Low */ - low?: string - /** Median */ - median?: string - /** Data points */ - list: Array +/** One cash currency entry in USAssetOverview */ +export interface UsCashEntry { + currency: string + frozenBuyCash: string + outstanding: string + settledCash: string + totalAmount: string + totalCash: string } -/** Historical metrics container */ -export interface ValuationHistoryMetrics { - /** PE history */ - pe?: ValuationHistoryMetric - /** PB history */ - pb?: ValuationHistoryMetric - /** PS history */ - ps?: ValuationHistoryMetric +/** Fee breakdown for an order. */ +export interface UsChargeDetail { + currency: string + totalAmount: string + items: Array } -/** One historical valuation data point. */ -export interface ValuationHistoryPoint { - /** Date (RFC 3339) */ - date: string - /** P/E ratio */ - pe: string - /** P/B ratio */ - pb: string - /** P/S ratio */ - ps: string +/** One fee category within USChargeDetail. */ +export interface UsChargeItem { + code: number + name: string + fees: Array } -/** Historical valuation response */ -export interface ValuationHistoryResponse { - /** Historical valuation data */ - history: ValuationHistoryData +/** US company dividends */ +export interface UsCompanyDividends { + recentDividends: UsRecentDividend + dividendHistory: Array + payoutRatios: Array + dividendPayoutHistory: Array } -/** Historical time-series for one valuation metric */ -export interface ValuationMetricData { - /** Description */ - desc: string - /** Historical high */ - high?: string - /** Historical low */ - low?: string - /** Historical median */ - median?: string - /** Data points */ - list: Array +/** US company overview */ +export interface UsCompanyOverview { + intro: string + marketCap: string + ccySymbol: string + topRankTags: Array + detailUrl: string + shareList: Array } -/** Valuation metrics container */ -export interface ValuationMetricsData { - /** PE ratio history */ - pe?: ValuationMetricData - /** PB ratio history */ - pb?: ValuationMetricData - /** PS ratio history */ - ps?: ValuationMetricData - /** Dividend yield history */ - dvdYld?: ValuationMetricData +/** Actual vs estimated value for one consensus metric. */ +export interface UsConsensusEstimate { + actual: string + estimate: string } -/** One valuation data point */ -export interface ValuationPoint { - /** Unix timestamp (seconds) */ - timestamp: number - /** Metric value */ - value?: string +/** One fiscal-year entry in USAnalystConsensus.list. */ +export interface UsConsensusItem { + ebit: UsConsensusEstimate + eps: UsConsensusEstimate + fiscalYear: number + reportTxt: string + revenue: UsConsensusEstimate } -/** Warrant sort by */ -export declare const enum WarrantSortBy { - /** Last done */ - LastDone = 0, - /** Change rate */ - ChangeRate = 1, - /** Change value */ - ChangeValue = 2, - /** Volume */ - Volume = 3, - /** Turnover */ - Turnover = 4, - /** Expiry date */ - ExpiryDate = 5, - /** Strike price */ - StrikePrice = 6, - /** Upper strike price */ - UpperStrikePrice = 7, - /** Lower strike price */ - LowerStrikePrice = 8, - /** Outstanding quantity */ - OutstandingQuantity = 9, - /** Outstanding ratio */ - OutstandingRatio = 10, - /** Premium */ - Premium = 11, - /** In/out of the bound */ - ItmOtm = 12, - /** Implied volatility */ - ImpliedVolatility = 13, - /** Greek value delta */ - Delta = 14, - /** Call price */ - CallPrice = 15, - /** Price interval from the call price */ - ToCallPrice = 16, - /** Effective leverage */ - EffectiveLeverage = 17, - /** Leverage ratio */ - LeverageRatio = 18, - /** Conversion ratio */ - ConversionRatio = 19, - /** Breakeven point */ - BalancePoint = 20, - /** Status */ - Status = 21 +/** One cryptocurrency holding in USAssetOverview */ +export interface UsCryptoEntry { + assetType: string + averageCost: string + symbol: string + currency: string + industryName: string } -/** Warrant status */ -export declare const enum WarrantStatus { - /** Suspend */ - Suspend = 0, - /** Prepare List */ - PrepareList = 1, - /** Normal */ - Normal = 2 +/** US cryptocurrency market overview */ +export interface UsCryptoOverview { + name: string + ticker: string + currency: string + allTimeHigh: string + allTimeHighDate: string + allTimeLow: string + allTimeLowDate: string + ipoDate: string + issuePrice: string + shares: string + officialWebAddress: string + /** User-facing symbol (e.g. "BTCUSD.BKKT"), converted from counter_id */ + symbol: string + baseAsset: string + logo: string + wikiUrl: string + /** Profile serialized as JSON string */ + profile: string } -/** Warrant type */ -export declare const enum WarrantType { - /** Unknown */ - Unknown = 0, - /** Call */ - Call = 1, - /** Put */ - Put = 2, - /** Bull */ - Bull = 3, - /** Bear */ - Bear = 4, - /** Inline */ - Inline = 5 +/** One fiscal-year row in dividend_history or payout_ratios. */ +export interface UsDividendHistoryItem { + fiscalYear: string + fiscalYearRange: string + totalShareholderYield: string + dividend: string + dividendYield: string + dividendGrowthRate: string + dividendPayoutRatio: string + dividendToCashflowRatio: string + netBuyback: string + netBuybackYield: string + netBuybackGrowthRate: string + netBuybackPayoutRatio: string + netBuybackToCashflowRatio: string + currency: string +} + +/** US dividend item */ +export interface UsDividendItem { + dividend: string + dividendType: string + exDate: string + paymentDate: string + recordDate: string +} + +/** One actual dividend payment event. */ +export interface UsDividendPayoutRecord { + dividend: string + dividendType: string + currency: string + exDate: string + paymentDate: string + recordDate: string + title: string + startTimeUnix: string +} + +/** US ETF dividend info */ +export interface UsetfDividendInfo { + dividendTtm: string + dividendYieldTtm: string + dividendFrequency: string + currency: string + fiscalYearInfo: Array +} + +/** US ETF file */ +export interface UsetfFile { + fileName: string + filePath: string + updateDate: string + code: string + format: string +} + +/** US ETF files response */ +export interface UsetfFilesResponse { + files: Array +} + +/** One balance-sheet entry in USFinancialOverview. */ +export interface UsFinancialBsItem { + debtAssetsRatio: string + totalAssets: string + totalLiabilities: string + report: UsReportPeriod +} + +/** One cash-flow entry in USFinancialOverview. */ +export interface UsFinancialCfItem { + operating: string + investing: string + financing: string + report: UsReportPeriod +} + +/** One income-statement entry in USFinancialOverview. */ +export interface UsFinancialIsItem { + revenue: string + netIncome: string + netMargin: string + report: UsReportPeriod +} + +/** US financial overview — income statement, balance sheet, and cash flow. */ +export interface UsFinancialOverview { + ccySymbol: string + reportType: string + isList: Array + bsList: Array + cfList: Array +} + +/** US financial statement */ +export interface UsFinancialStatement { + currency: string + report: string + list: Array + emptyFields: Array } -export interface USOrderHistory { - execType: number - status: string - price: string - qty: string - time: string - msg: string - isManually: boolean - oppPartyId: string - trdMatchId: string - operator: string - opEntrustWay: string - cxlRejResponseTo: number - withdrawalReason: string - oppName: string - execId: string +/** One financial field within a USFinancialStatementPeriod. */ +export interface UsFinancialStatementField { + displayOrder: number + field: string + id: string + level: number + name: string + value: string + valueType: string + yoy: string } -export interface USButtonControl { - withdraw: number - replace: number - exceptionable: Array + +/** One reporting period in USFinancialStatement. */ +export interface UsFinancialStatementPeriod { + ffPeriod: string + ffYear: number + fields: Array + fpEnd: string + reportTxt: string + rptDate: string } -export interface USChargeItem { - code: number - name: string - fees: Array + +/** Per-fiscal-year dividend row for a US ETF. */ +export interface UsFiscalYearDividend { + dividend: string + dividendYield: string + fiscalYear: string + currency: string + fiscalYearRange: string } -export interface USChargeDetail { + +/** US key financial metrics — ratios and indicators per reporting period. */ +export interface UsKeyFinancialMetrics { currency: string - totalAmount: string - items: Array + report: string + emptyFields: Array + list: Array } -export interface USAttachedOrder { - attachedTypeDisplay: number - executedQty: string - quantity: string - status: string - triggerPrice: string - orderId: string - gtd: string - timeInForce: number - tag: number - activateOrderType: string - activateRth: number - submitPrice: string - symbol: string - withdrawn: boolean + +/** One period entry in USKeyFinancialMetrics. */ +export interface UsKeyMetricItem { + ffPeriod: string + ffYear: number + fpEnd: string + reportTxt: string + rptDate: string + fields: Array } -export interface USOrderDetail { + +/** Full typed order object within USOrderDetailResponse. */ +export interface UsOrderDetail { id: string aaid: string accountChannel: string @@ -6252,93 +7007,40 @@ export interface USOrderDetail { strikePrice: string contractSize: string monitorPrice: string - buttonControl: USButtonControl - chargeDetail: USChargeDetail | null - attachedOrders: Array - orderHistories: Array -} -export interface USOrderDetailResponse { - order: USOrderDetail | null - currentAttachedOrder: USOrderDetail | null - currentMillisecond: string + buttonControl: UsButtonControl + chargeDetail?: UsChargeDetail + attachedOrders: Array + orderHistories: Array } -export interface USReportPeriod { - startDate: string - endDate: string - reportTxt: string -} -export interface USFinancialISItem { - revenue: string - netIncome: string - netMargin: string - report: USReportPeriod -} -export interface USFinancialBSItem { - debtAssetsRatio: string - totalAssets: string - totalLiabilities: string - report: USReportPeriod -} -export interface USFinancialCFItem { - operating: string - investing: string - financing: string - report: USReportPeriod -} -export interface USFinancialOverview { - ccySymbol: string - reportType: string - isList: Array - bsList: Array - cfList: Array -} -export interface USKeyMetricItem { - ffPeriod: string - ffYear: number - fpEnd: string - reportTxt: string - rptDate: string - fields: Array -} -export interface USKeyFinancialMetrics { - currency: string - report: string - emptyFields: Array - list: Array -} -export interface USAIChatData { - agentId: string - handoffAgentId: string - symbol: string - text: string - chatType: string - workflowType: string -} -export interface USConsensusEstimate { - actual: string - estimate: string -} -export interface USConsensusItem { - ebit: USConsensusEstimate - eps: USConsensusEstimate - fiscalYear: number - reportTxt: string - revenue: USConsensusEstimate -} -export interface USAnalystConsensus { - aiSummary: string - aichatData: USAIChatData - currency: string - report: string - list: Array - optReports: Array - h5Data: unknown +/** Response for us_order_detail. */ +export interface UsOrderDetailResponse { + order?: UsOrderDetail + currentAttachedOrder?: UsOrderDetail + currentMillisecond: string } -// ── US-market types ──────────────────────────────────────────────────────── +/** One order state-transition entry within USOrderDetail. */ +export interface UsOrderHistory { + execType: number + status: string + price: string + qty: string + time: string + msg: string + isManually: boolean + oppPartyId: string + trdMatchId: string + operator: string + opEntrustWay: string + cxlRejResponseTo: number + withdrawalReason: string + oppName: string + execId: string +} -export interface USRankTag { +/** Industry rank tag */ +export interface UsRankTag { key: string location: number title: string @@ -6347,178 +7049,49 @@ export interface USRankTag { highlightText: string } -export interface USSharelistItem { - chg: string - id: string - name: string -} - -export interface USCompanyOverview { - intro: string - marketCap: string - ccySymbol: string - topRankTags: Array - detailUrl: string - shareList: Array -} - -export interface USValuationMetric { - circle: string - part: string - metric: string - desc: string - industryMedian: string -} - -export interface USValuationOverview { - metrics: Record - indicator: string - range: number - date: string - ccySymbol: string - aichatData: USAIChatData - aiSummary: string -} - -export interface USFinancialStatementField { - displayOrder: number - field: string - id: string - level: number - name: string - value: string - valueType: string - yoy: string -} -export interface USFinancialStatementPeriod { - ffPeriod: string - ffYear: number - fields: Array - fpEnd: string - reportTxt: string - rptDate: string -} -export interface USFinancialStatement { - currency: string - report: string - list: Array - emptyFields: Array -} - -export interface USFiscalYearDividend { - dividend: string - dividendYield: string - fiscalYear: string - currency: string - fiscalYearRange: string +/** Realized P&L response for a US account */ +export interface UsRealizedPl { + realizedPlList: Array } -export interface USETFDividendInfo { - dividendTtm: string - dividendYieldTtm: string - dividendFrequency: string +/** One asset-category entry in USRealizedPL */ +export interface UsRealizedPlEntry { + category: number currency: string - fiscalYearInfo: Array + metrics: Array } -export interface USDividendItem { - dividend: string - dividendType: string - exDate: string - paymentDate: string - recordDate: string +/** One time-period metric in USRealizedPLEntry */ +export interface UsRealizedPlMetric { + amount: string + period: number + rate: string } -export interface USRecentDividend { +/** TTM dividend summary within USCompanyDividends. */ +export interface UsRecentDividend { dividendTtm: string dividendYieldTtm: string payouts: string currency: string } -export interface USDividendHistoryItem { - fiscalYear: string - fiscalYearRange: string - totalShareholderYield: string - dividend: string - dividendYield: string - dividendGrowthRate: string - dividendPayoutRatio: string - dividendToCashflowRatio: string - netBuyback: string - netBuybackYield: string - netBuybackGrowthRate: string - netBuybackPayoutRatio: string - netBuybackToCashflowRatio: string - currency: string -} -export interface USDividendPayoutRecord { - dividend: string - dividendType: string - currency: string - exDate: string - paymentDate: string - recordDate: string - title: string - startTimeUnix: string -} -export interface USCompanyDividends { - recentDividends: USRecentDividend - dividendHistory: Array - payoutRatios: Array - dividendPayoutHistory: Array -} - -export interface USETFFile { - fileName: string - filePath: string - updateDate: string - code: string - format: string -} - -export interface USETFFilesResponse { - files: Array -} - -export interface USCryptoOverview { - symbol: string - name: string - ticker: string - baseAsset: string - currency: string - allTimeHigh: string - allTimeHighDate: string - allTimeLow: string - allTimeLowDate: string - ipoDate: string - issuePrice: string - shares: string - officialWebAddress: string - logo: string - wikiUrl: string - /** Profile / description as a JSON string */ - profile: string -} -export interface USCashEntry { - currency: string - frozenBuyCash: string - outstanding: string - settledCash: string - totalAmount: string - totalCash: string +/** One reporting-period window shared by IS/BS/CF entries. */ +export interface UsReportPeriod { + startDate: string + endDate: string + reportTxt: string } -export interface USCryptoEntry { - assetType: string - averageCost: string - /** User-facing symbol, e.g. "BTCUSD.BKKT" */ - symbol: string - currency: string - industryName: string +/** One entry in USCompanyOverview.share_list. */ +export interface UsSharelistItem { + chg: string + id: string + name: string } -export interface USStockEntry { +/** One stock/equity position in USAssetOverview */ +export interface UsStockEntry { symbol: string fullSymbol: string assetType: string @@ -6543,32 +7116,288 @@ export interface USStockEntry { industryCounterId: string industryName: string } -export interface USAssetOverview { - accountType: string - /** Unix timestamp (seconds) */ - assetTimestamp: number - cashBuyPower: string - overnightBuyPower: string - currency: string - cashList: Array - stockList: Array - optionList: Array - cryptoList: Array - multiLeg: unknown + +/** One valuation metric entry in USValuationOverview.metrics */ +export interface UsValuationMetric { + circle: string + part: string + metric: string + desc: string + industryMedian: string } -export interface USRealizedPLMetric { - amount: string - period: number - rate: string +/** US valuation overview */ +export interface UsValuationOverview { + metrics: Record + indicator: string + range: number + date: string + ccySymbol: string + aichatData: USAIChatData + aiSummary: string } -export interface USRealizedPLEntry { - category: number +/** One security's valuation comparison item. */ +export interface ValuationComparisonItem { + /** Symbol (e.g. `"AAPL.US"`) */ + symbol: string + /** Security name */ + name: string + /** Currency */ currency: string - metrics: Array + /** Market capitalisation */ + marketValue: string + /** Latest closing price */ + priceClose: string + /** P/E ratio */ + pe: string + /** P/B ratio */ + pb: string + /** P/S ratio */ + ps: string + /** Return on equity */ + roe: string + /** Earnings per share */ + eps: string + /** Book value per share */ + bps: string + /** Dividends per share */ + dps: string + /** Dividend yield */ + divYld: string + /** Total assets */ + assets: string + /** Historical valuation points */ + history: Array +} + +/** Valuation comparison response. */ +export interface ValuationComparisonResponse { + /** Valuation comparison items */ + list: Array +} + +/** Valuation metrics response */ +export interface ValuationData { + /** Valuation metrics */ + metrics: ValuationMetricsData +} + +/** Distribution statistics for one valuation metric */ +export interface ValuationDist { + /** Minimum value */ + low?: string + /** Maximum value */ + high?: string + /** Median value */ + median?: string + /** Current value */ + value?: string + /** Percentile ranking */ + ranking?: string + /** Ordinal rank index */ + rankIndex: string + /** Total securities in industry */ + rankTotal: string +} + +/** Historical valuation container */ +export interface ValuationHistoryData { + /** Historical metrics */ + metrics: ValuationHistoryMetrics +} + +/** Historical data for one valuation metric */ +export interface ValuationHistoryMetric { + /** Description */ + desc: string + /** High */ + high?: string + /** Low */ + low?: string + /** Median */ + median?: string + /** Data points */ + list: Array +} + +/** Historical metrics container */ +export interface ValuationHistoryMetrics { + /** PE history */ + pe?: ValuationHistoryMetric + /** PB history */ + pb?: ValuationHistoryMetric + /** PS history */ + ps?: ValuationHistoryMetric +} + +/** One historical valuation data point. */ +export interface ValuationHistoryPoint { + /** Date (RFC 3339) */ + date: string + /** P/E ratio */ + pe: string + /** P/B ratio */ + pb: string + /** P/S ratio */ + ps: string +} + +/** Historical valuation response */ +export interface ValuationHistoryResponse { + /** Historical valuation data */ + history: ValuationHistoryData +} + +/** Historical time-series for one valuation metric */ +export interface ValuationMetricData { + /** Description */ + desc: string + /** Historical high */ + high?: string + /** Historical low */ + low?: string + /** Historical median */ + median?: string + /** Data points */ + list: Array +} + +/** Valuation metrics container */ +export interface ValuationMetricsData { + /** PE ratio history */ + pe?: ValuationMetricData + /** PB ratio history */ + pb?: ValuationMetricData + /** PS ratio history */ + ps?: ValuationMetricData + /** Dividend yield history */ + dvdYld?: ValuationMetricData +} + +/** One valuation data point */ +export interface ValuationPoint { + /** Unix timestamp (seconds) */ + timestamp: number + /** Metric value */ + value?: string +} + +/** Warrant sort by */ +export declare const enum WarrantSortBy { + /** Last done */ + LastDone = 0, + /** Change rate */ + ChangeRate = 1, + /** Change value */ + ChangeValue = 2, + /** Volume */ + Volume = 3, + /** Turnover */ + Turnover = 4, + /** Expiry date */ + ExpiryDate = 5, + /** Strike price */ + StrikePrice = 6, + /** Upper strike price */ + UpperStrikePrice = 7, + /** Lower strike price */ + LowerStrikePrice = 8, + /** Outstanding quantity */ + OutstandingQuantity = 9, + /** Outstanding ratio */ + OutstandingRatio = 10, + /** Premium */ + Premium = 11, + /** In/out of the bound */ + ItmOtm = 12, + /** Implied volatility */ + ImpliedVolatility = 13, + /** Greek value delta */ + Delta = 14, + /** Call price */ + CallPrice = 15, + /** Price interval from the call price */ + ToCallPrice = 16, + /** Effective leverage */ + EffectiveLeverage = 17, + /** Leverage ratio */ + LeverageRatio = 18, + /** Conversion ratio */ + ConversionRatio = 19, + /** Breakeven point */ + BalancePoint = 20, + /** Status */ + Status = 21 +} + +/** Warrant status */ +export declare const enum WarrantStatus { + /** Suspend */ + Suspend = 0, + /** Prepare List */ + PrepareList = 1, + /** Normal */ + Normal = 2 +} + +/** Warrant type */ +export declare const enum WarrantType { + /** Unknown */ + Unknown = 0, + /** Call */ + Call = 1, + /** Put */ + Put = 2, + /** Bull */ + Bull = 3, + /** Bear */ + Bear = 4, + /** Inline */ + Inline = 5 +} + +/** `inputs` of a `workflow_started` stream event */ +export interface WorkflowStartedInputs { + /** ID of the owning conversation */ + chatId: number + /** Conversation identifier */ + chatUid: string + /** Message ID of this round */ + messageId: string + /** The question that was asked */ + query: string +} + +/** + * Payload of a `workflow_started` stream event, observed right after + * `chat_started` + */ +export interface WorkflowStartedPayload { + /** Whether this run's answer was served from a cache */ + hitCache: boolean + /** Echoes the run's inputs */ + inputs: WorkflowStartedInputs + /** Unix timestamp in seconds */ + startedAt: number + /** Internal workflow run ID */ + workflowId: number +} + +/** A Workspace the current account belongs to */ +export interface Workspace { + /** Workspace ID */ + id: string + /** Workspace name */ + name: string + /** Creation time, Unix timestamp in seconds */ + createdAt: number + /** Last updated time, Unix timestamp in seconds */ + updatedAt: number } -export interface USRealizedPL { - realizedPlList: Array +/** Response for `AgentContext.workspaces` */ +export interface WorkspacesResponse { + /** Workspaces the current account belongs to */ + workspaces: Array } diff --git a/nodejs/index.js b/nodejs/index.js index 5e5192555e..6fa66ae9c7 100644 --- a/nodejs/index.js +++ b/nodejs/index.js @@ -394,6 +394,7 @@ if (!nativeBinding) { module.exports = nativeBinding module.exports.AccountBalance = nativeBinding.AccountBalance +module.exports.AgentContext = nativeBinding.AgentContext module.exports.AlertContext = nativeBinding.AlertContext module.exports.AllExecutionsResponse = nativeBinding.AllExecutionsResponse module.exports.AssetContext = nativeBinding.AssetContext @@ -494,6 +495,7 @@ module.exports.CalendarCategory = nativeBinding.CalendarCategory module.exports.CashFlowDirection = nativeBinding.CashFlowDirection module.exports.ChargeCategoryCode = nativeBinding.ChargeCategoryCode module.exports.CommissionFreeStatus = nativeBinding.CommissionFreeStatus +module.exports.ConversationStatus = nativeBinding.ConversationStatus module.exports.DCAFrequency = nativeBinding.DCAFrequency module.exports.DCAStatus = nativeBinding.DCAStatus module.exports.DeductionStatus = nativeBinding.DeductionStatus diff --git a/nodejs/src/agent/context.rs b/nodejs/src/agent/context.rs new file mode 100644 index 0000000000..9902f7c704 --- /dev/null +++ b/nodejs/src/agent/context.rs @@ -0,0 +1,217 @@ +use std::{collections::HashMap, sync::Arc}; + +use napi::{Result, threadsafe_function::ThreadsafeFunctionCallMode}; + +use crate::{agent::types::*, config::Config, error::ErrorNewType, utils::JsCallback}; + +/// AI Agent conversation context. +/// +/// Reference: +#[napi_derive::napi] +#[derive(Clone)] +pub struct AgentContext { + ctx: longbridge::AgentContext, +} + +#[napi_derive::napi] +impl AgentContext { + /// Create a new AgentContext. + #[napi] + pub fn new(config: &Config) -> AgentContext { + Self { + ctx: longbridge::AgentContext::new(Arc::new(config.0.clone())), + } + } + + /// List the Workspaces the current account belongs to. + /// + /// #### Example + /// + /// ```javascript + /// const { Config, AgentContext } = require('longbridge'); + /// + /// const ctx = AgentContext.new(config); + /// const resp = await ctx.workspaces(); + /// console.log(resp); + /// ``` + #[napi] + pub async fn workspaces(&self) -> Result { + Ok(self.ctx.workspaces().await.map_err(ErrorNewType)?.into()) + } + + /// List the Agents in the specified Workspace. + /// + /// `page`/`limit` control pagination; `name` fuzzy-searches by Agent name. + /// All three are optional. + /// + /// #### Example + /// + /// ```javascript + /// const { Config, AgentContext } = require('longbridge'); + /// + /// const ctx = AgentContext.new(config); + /// const resp = await ctx.agents(workspaceId); + /// console.log(resp); + /// ``` + #[napi] + pub async fn agents( + &self, + workspace_id: String, + page: Option, + limit: Option, + name: Option, + ) -> Result { + let mut opts = longbridge::agent::GetAgentsOptions::new(); + if let Some(page) = page { + opts = opts.page(page); + } + if let Some(limit) = limit { + opts = opts.limit(limit); + } + if let Some(name) = name { + opts = opts.name(name); + } + Ok(self + .ctx + .agents(workspace_id, opts) + .await + .map_err(ErrorNewType)? + .into()) + } + + /// Start a conversation with the specified Agent, blocking until the run + /// succeeds, is interrupted, or fails. + /// + /// #### Example + /// + /// ```javascript + /// const { Config, AgentContext } = require('longbridge'); + /// + /// const ctx = AgentContext.new(config); + /// const resp = await ctx.conversation(agentId, "How has Tesla stock performed recently?"); + /// console.log(resp); + /// ``` + #[napi] + pub async fn conversation( + &self, + agent_id: String, + query: String, + chat_uid: Option, + ) -> Result { + Ok(self + .ctx + .conversation(agent_id, query, chat_uid) + .await + .map_err(ErrorNewType)? + .into()) + } + + /// Resume an interrupted conversation, blocking until the run succeeds, is + /// interrupted again, or fails. + /// + /// `answersByToolCall` is keyed by `toolCallId` (see `Interrupt`), each + /// value being a map of question text to answer. + #[napi] + pub async fn continue_conversation( + &self, + agent_id: String, + chat_uid: String, + message_id: String, + answers_by_tool_call: HashMap>, + ) -> Result { + Ok(self + .ctx + .continue_conversation(agent_id, chat_uid, message_id, answers_by_tool_call) + .await + .map_err(ErrorNewType)? + .into()) + } + + /// Start a conversation with the specified Agent, invoking `callback` for + /// every progress event observed over SSE, and resolving to the final + /// `ConversationResponse` once the run finishes (this is the same shape + /// `conversation` returns). + /// + /// #### Example + /// + /// ```javascript + /// const { Config, AgentContext } = require('longbridge'); + /// + /// const ctx = AgentContext.new(config); + /// const resp = await ctx.conversationStreamed( + /// agentId, + /// "How has Tesla stock performed recently?", + /// undefined, + /// (err, event) => console.log(event), + /// ); + /// console.log(resp); + /// ``` + // Design note: unlike `QuoteContext::set_on_quote` (a plain, non-async + // `fn` that takes a `Function<...>` and builds the threadsafe function + // itself), `callback` here is declared as `JsCallback` + // (i.e. `ThreadsafeFunction<...>`) directly. napi-rs converts the JS + // function into a `ThreadsafeFunction` at the FFI boundary before this + // `async fn`'s body ever runs, so no `!Send` `Function` value is ever + // held across an `.await` point. Accepting a bare `Function` here and + // building the threadsafe function manually inside the body — mirroring + // `set_on_quote` — makes the generated future `!Send` (`Function` wraps + // raw `napi_env`/`napi_value` pointers), and `execute_tokio_future` + // requires the future backing every `#[napi] async fn` to be `Send`. + // `set_on_quote` itself avoids this only because it is not async. + #[napi( + ts_args_type = "agentId: string, query: string, chatUid: string | undefined | null, callback: (err: null | Error, event: ConversationStreamEvent) => void" + )] + pub async fn conversation_streamed( + &self, + agent_id: String, + query: String, + chat_uid: Option, + callback: JsCallback, + ) -> Result { + let stream = self + .ctx + .conversation_streamed(agent_id, query, chat_uid) + .await + .map_err(ErrorNewType)?; + Ok( + longbridge::agent::drive_conversation_stream(stream, move |ev| { + callback.call(Ok(ev.into()), ThreadsafeFunctionCallMode::Blocking); + }) + .await + .map_err(ErrorNewType)? + .into(), + ) + } + + /// Resume an interrupted conversation, invoking `callback` for every + /// progress event observed over SSE, and resolving to the final + /// `ConversationResponse` once the run finishes. + /// + /// `answersByToolCall` is keyed by `toolCallId` (see `Interrupt`), each + /// value being a map of question text to answer. + #[napi( + ts_args_type = "agentId: string, chatUid: string, messageId: string, answersByToolCall: Record>, callback: (err: null | Error, event: ConversationStreamEvent) => void" + )] + pub async fn continue_conversation_streamed( + &self, + agent_id: String, + chat_uid: String, + message_id: String, + answers_by_tool_call: HashMap>, + callback: JsCallback, + ) -> Result { + let stream = self + .ctx + .continue_conversation_streamed(agent_id, chat_uid, message_id, answers_by_tool_call) + .await + .map_err(ErrorNewType)?; + Ok( + longbridge::agent::drive_conversation_stream(stream, move |ev| { + callback.call(Ok(ev.into()), ThreadsafeFunctionCallMode::Blocking); + }) + .await + .map_err(ErrorNewType)? + .into(), + ) + } +} diff --git a/nodejs/src/agent/mod.rs b/nodejs/src/agent/mod.rs new file mode 100644 index 0000000000..0561d4d5a5 --- /dev/null +++ b/nodejs/src/agent/mod.rs @@ -0,0 +1,2 @@ +pub mod context; +pub mod types; diff --git a/nodejs/src/agent/types.rs b/nodejs/src/agent/types.rs new file mode 100644 index 0000000000..9eead09794 --- /dev/null +++ b/nodejs/src/agent/types.rs @@ -0,0 +1,1651 @@ +use longbridge::agent::types as lb; + +/// A Workspace the current account belongs to +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct Workspace { + /// Workspace ID + pub id: String, + /// Workspace name + pub name: String, + /// Creation time, Unix timestamp in seconds + pub created_at: i64, + /// Last updated time, Unix timestamp in seconds + pub updated_at: i64, +} +impl From for Workspace { + fn from(v: lb::Workspace) -> Self { + Self { + id: v.id, + name: v.name, + created_at: v.created_at, + updated_at: v.updated_at, + } + } +} + +/// Response for `AgentContext.workspaces` +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct WorkspacesResponse { + /// Workspaces the current account belongs to + pub workspaces: Vec, +} +impl From for WorkspacesResponse { + fn from(v: lb::WorkspacesResponse) -> Self { + Self { + workspaces: v.workspaces.into_iter().map(Into::into).collect(), + } + } +} + +/// An Agent in a Workspace +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct Agent { + /// Agent UID, used as the path parameter of `AgentContext.conversation` + pub uid: String, + /// Agent name + pub name: String, + /// Agent description + pub description: String, + /// Agent mode, e.g. `chat` + pub mode: String, + /// Icon URL + pub icon: String, + /// Whether published; only published Agents can start conversations + pub is_published: bool, + /// Publish time, Unix timestamp in seconds; 0 if unpublished + pub published_at: i64, + /// Creation time, Unix timestamp in seconds + pub created_at: i64, + /// Last updated time, Unix timestamp in seconds + pub updated_at: i64, +} +impl From for Agent { + fn from(v: lb::Agent) -> Self { + Self { + uid: v.uid, + name: v.name, + description: v.description, + mode: v.mode, + icon: v.icon, + is_published: v.is_published, + published_at: v.published_at, + created_at: v.created_at, + updated_at: v.updated_at, + } + } +} + +/// Response for `AgentContext.agents` +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct AgentsResponse { + /// Agent list + pub agents: Vec, + /// Total number of matching Agents + pub total: i32, +} +impl From for AgentsResponse { + fn from(v: lb::AgentsResponse) -> Self { + Self { + agents: v.agents.into_iter().map(Into::into).collect(), + total: v.total, + } + } +} + +/// Final run status of a conversation +#[napi_derive::napi] +#[derive(Debug, Clone, Copy)] +pub enum ConversationStatus { + /// The run completed successfully + Succeeded, + /// The run is paused, waiting for `AgentContext.continueConversation` + Interrupted, + /// The run failed + Failed, + /// The run was stopped + Stopped, +} +impl From for ConversationStatus { + fn from(v: lb::ConversationStatus) -> Self { + match v { + lb::ConversationStatus::Succeeded => ConversationStatus::Succeeded, + lb::ConversationStatus::Interrupted => ConversationStatus::Interrupted, + lb::ConversationStatus::Failed => ConversationStatus::Failed, + lb::ConversationStatus::Stopped => ConversationStatus::Stopped, + } + } +} + +/// A source referenced by the answer +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct Reference { + /// Reference index + pub index: i32, + /// Reference title + pub title: String, + /// Reference URL + pub url: String, +} +impl From for Reference { + fn from(v: lb::Reference) -> Self { + Self { + index: v.index, + title: v.title, + url: v.url, + } + } +} + +/// One option of a `Question` +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct QuestionOption { + /// Option text + pub description: String, +} +impl From for QuestionOption { + fn from(v: lb::QuestionOption) -> Self { + Self { + description: v.description, + } + } +} + +/// One question the Agent needs you to answer +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct Question { + /// Question text + pub question: String, + /// Options; empty means free-form answer + pub options: Vec, + /// Whether multiple options may be selected + pub multi_select: bool, +} +impl From for Question { + fn from(v: lb::Question) -> Self { + Self { + question: v.question, + options: v.options.into_iter().map(Into::into).collect(), + multi_select: v.multi_select, + } + } +} + +/// Present when a conversation run is interrupted, waiting for +/// `AgentContext.continueConversation` +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct Interrupt { + /// ID of the node that triggered the interrupt + pub node_id: String, + /// Tool call ID of this inquiry; used as the answer key when continuing + pub tool_call_id: String, + /// Questions you need to answer + pub questions: Vec, + /// ID of the paused message + pub message_id: i64, + /// ID of the owning conversation + pub chat_id: i64, +} +impl From for Interrupt { + fn from(v: lb::Interrupt) -> Self { + Self { + node_id: v.node_id, + tool_call_id: v.tool_call_id, + questions: v.questions.into_iter().map(Into::into).collect(), + message_id: v.message_id, + chat_id: v.chat_id, + } + } +} + +/// Present when a conversation run failed +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct AgentError { + /// Error code + pub code: i32, + /// Error message + pub message: String, +} +impl From for AgentError { + fn from(v: lb::AgentError) -> Self { + Self { + code: v.code, + message: v.message, + } + } +} + +/// Response for `AgentContext.conversation`, +/// `AgentContext.continueConversation`, and the final result of the streamed +/// counterparts +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ConversationResponse { + /// Conversation identifier, used for follow-up questions and + /// troubleshooting + pub chat_uid: String, + /// Message ID of this round + pub message_id: String, + /// Final run status + pub status: ConversationStatus, + /// Final answer text; valid when `status` is `succeeded` + pub answer: String, + /// Sources referenced by the answer + pub references: Option>, + /// Run duration in seconds + pub elapsed_time: f64, + /// Present only when `status` is `interrupted` + pub interrupt: Option, + /// Present only when the run failed + pub error: Option, +} +impl From for ConversationResponse { + fn from(v: lb::ConversationResponse) -> Self { + Self { + chat_uid: v.chat_uid, + message_id: v.message_id, + status: v.status.into(), + answer: v.answer, + references: v + .references + .map(|refs| refs.into_iter().map(Into::into).collect()), + elapsed_time: v.elapsed_time, + interrupt: v.interrupt.map(Into::into), + error: v.error.map(Into::into), + } + } +} + +/// Payload of a `chat_started` stream event +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ChatStartedPayload { + /// Conversation identifier + pub chat_uid: String, + /// Message ID of this round + pub message_id: String, +} +impl From for ChatStartedPayload { + fn from(v: lb::ChatStartedPayload) -> Self { + Self { + chat_uid: v.chat_uid, + message_id: v.message_id, + } + } +} + +/// Payload of a `message` stream event — an incremental text chunk. This is +/// the highest-frequency event; concatenate `text` fragments in arrival +/// order. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct MessagePayload { + /// Incremental text fragment + pub text: String, + /// `answer` — final answer text; `think` — reasoning process; `process` + /// — stage progress description + pub message_type: String, + /// Identifier of the stream segment this fragment belongs to. Fragments + /// with the same `key` form one continuous block — group by `key` when + /// rendering + pub key: String, + /// Time this segment started, Unix timestamp in seconds + pub started_at: i64, + /// Stage identifier; only present when `messageType` is `"process"` + pub stage: String, + /// Stage title while running; only present when `messageType` is + /// `"process"` + pub stage_title: String, + /// Stage title after it finishes; only present when `messageType` is + /// `"process"` + pub stage_finished_title: String, + /// Extra payload attached to the fragment; usually absent + pub outputs: Option, +} +impl From for MessagePayload { + fn from(v: lb::MessagePayload) -> Self { + Self { + text: v.text, + message_type: v.message_type, + key: v.key, + started_at: v.started_at, + stage: v.stage, + stage_title: v.stage_title, + stage_finished_title: v.stage_finished_title, + outputs: v.outputs, + } + } +} + +/// `inputs` of a `workflow_started` stream event +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct WorkflowStartedInputs { + /// ID of the owning conversation + pub chat_id: i64, + /// Conversation identifier + pub chat_uid: String, + /// Message ID of this round + pub message_id: String, + /// The question that was asked + pub query: String, +} +impl From for WorkflowStartedInputs { + fn from(v: lb::WorkflowStartedInputs) -> Self { + Self { + chat_id: v.chat_id, + chat_uid: v.chat_uid, + message_id: v.message_id, + query: v.query, + } + } +} + +/// Payload of a `workflow_started` stream event, observed right after +/// `chat_started` +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct WorkflowStartedPayload { + /// Whether this run's answer was served from a cache + pub hit_cache: bool, + /// Echoes the run's inputs + pub inputs: WorkflowStartedInputs, + /// Unix timestamp in seconds + pub started_at: i64, + /// Internal workflow run ID + pub workflow_id: i64, +} +impl From for WorkflowStartedPayload { + fn from(v: lb::WorkflowStartedPayload) -> Self { + Self { + hit_cache: v.hit_cache, + inputs: v.inputs.into(), + started_at: v.started_at, + workflow_id: v.workflow_id, + } + } +} + +/// Payload of a `chat_finished` stream event, observed once all `message` +/// events for this round have been sent, shortly before `workflow_finished` +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ChatFinishedPayload { + /// ID of the owning conversation + pub chat_id: i64, + /// Conversation identifier + pub chat_uid: String, + /// Message ID of this round + pub message_id: String, + /// Empty string in every run observed so far + pub error: String, + /// Empty string in every run observed so far + pub error_message: String, +} +impl From for ChatFinishedPayload { + fn from(v: lb::ChatFinishedPayload) -> Self { + Self { + chat_id: v.chat_id, + chat_uid: v.chat_uid, + message_id: v.message_id, + error: v.error, + error_message: v.error_message, + } + } +} + +/// Payload of a `chat_title_updated` stream event — the server auto-generates +/// a short title for the conversation as a UI convenience. Can arrive before +/// *or* after `workflow_finished`; not tied to the run's outcome. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ChatTitleUpdatedPayload { + /// ID of the owning conversation + pub chat_id: i64, + /// Conversation identifier + pub chat_uid: String, + /// Where the title came from, e.g. `"ai_generated"` + pub source: String, + /// The new (possibly truncated) title + pub title: String, + /// Unix timestamp in seconds + pub updated_at: i64, +} +impl From for ChatTitleUpdatedPayload { + fn from(v: lb::ChatTitleUpdatedPayload) -> Self { + Self { + chat_id: v.chat_id, + chat_uid: v.chat_uid, + source: v.source, + title: v.title, + updated_at: v.updated_at, + } + } +} + +/// Payload of a `thinking_started` stream event — the Agent has entered the +/// reasoning phase (analyzing the question, planning tool calls). Between +/// this and `ThinkingFinished`, `Message` events with `messageType == +/// "think"` and tool-call events may arrive. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ThinkingStartedPayload { + /// Start time, Unix timestamp in seconds + pub started_at: i64, +} +impl From for ThinkingStartedPayload { + fn from(v: lb::ThinkingStartedPayload) -> Self { + Self { + started_at: v.started_at, + } + } +} + +/// Payload of a `thinking_finished` stream event — the reasoning phase is +/// over; answer text (`Message` with `messageType == "answer"`) follows. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ThinkingFinishedPayload { + /// Finish time, Unix timestamp in seconds + pub finished_at: i64, + /// Reasoning duration in seconds + pub elapsed_time: i32, +} +impl From for ThinkingFinishedPayload { + fn from(v: lb::ThinkingFinishedPayload) -> Self { + Self { + finished_at: v.finished_at, + elapsed_time: v.elapsed_time, + } + } +} + +/// Payload of a `node_tool_use_started` stream event — an ordinary tool call +/// has started. Match it to its `NodeToolUseFinished` counterpart by +/// `toolUseId`. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct NodeToolUseStartedPayload { + /// Unique ID of this call; matches the finished event + pub tool_use_id: String, + /// Localized display name of the tool + pub tool_name: String, + /// Locale-stable tool identifier; use this for logic keyed on the tool + /// kind + pub tool_func_name: String, + /// Call arguments as a JSON string + pub tool_args: String, + /// Progress text suitable for direct display, e.g. `"Searching the + /// web…"` + pub tips: String, + /// Short tags accompanying `tips`; may be omitted + pub tip_chips: Vec, + /// Round number. Calls in the same round (same `iteration`) run in + /// parallel + pub iteration: i32, + /// Start time, Unix timestamp in seconds + pub started_at: i64, +} +impl From for NodeToolUseStartedPayload { + fn from(v: lb::NodeToolUseStartedPayload) -> Self { + Self { + tool_use_id: v.tool_use_id, + tool_name: v.tool_name, + tool_func_name: v.tool_func_name, + tool_args: v.tool_args, + tips: v.tips, + tip_chips: v.tip_chips, + iteration: v.iteration, + started_at: v.started_at, + } + } +} + +/// `outputs` of a `NodeToolUseFinishedPayload` — only carries fields meant +/// for display +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct NodeToolUseOutputs { + /// Sources referenced by the tool result + pub references: Option>, + /// Domains of the referenced sources + pub reference_domains: Option>, + /// The query the tool executed + pub query: Option, + /// Raw response text of the tool + pub text: Option, + /// Parsed request arguments + pub tool_args: Option, + /// Structured result; present only for selected tools + pub data: Option, +} +impl From for NodeToolUseOutputs { + fn from(v: lb::NodeToolUseOutputs) -> Self { + Self { + references: v + .references + .map(|refs| refs.into_iter().map(Into::into).collect()), + reference_domains: v.reference_domains, + query: v.query, + text: v.text, + tool_args: v.tool_args, + data: v.data, + } + } +} + +/// Payload of a `node_tool_use_finished` stream event — the tool call has +/// ended. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct NodeToolUseFinishedPayload { + /// Matches the `toolUseId` of the started event + pub tool_use_id: String, + /// `succeeded` / `failed` + pub status: String, + /// Error description on failure + pub error: String, + /// Call duration in seconds + pub elapsed_time: f64, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Localized display name + pub tool_name: String, + /// Locale-stable tool identifier + pub tool_func_name: String, + /// Call arguments as a JSON string + pub tool_args: String, + /// Tool category + pub tool_type: String, + /// Progress text + pub tips: String, + /// Short tags; may be omitted + pub tip_chips: Vec, + /// Round number + pub iteration: i32, + /// `true` if the call happened during the thinking phase + pub is_thinking: bool, + /// Filtered call results, for display + pub outputs: NodeToolUseOutputs, +} +impl From for NodeToolUseFinishedPayload { + fn from(v: lb::NodeToolUseFinishedPayload) -> Self { + Self { + tool_use_id: v.tool_use_id, + status: v.status, + error: v.error, + elapsed_time: v.elapsed_time, + started_at: v.started_at, + tool_name: v.tool_name, + tool_func_name: v.tool_func_name, + tool_args: v.tool_args, + tool_type: v.tool_type, + tips: v.tips, + tip_chips: v.tip_chips, + iteration: v.iteration, + is_thinking: v.is_thinking, + outputs: v.outputs.into(), + } + } +} + +/// Payload of a `subagent_started` stream event. When the Agent spawns a +/// subagent to work on a sub-task, the subagent's lifecycle is reported with +/// this dedicated event family instead of `nodeToolUse*`. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct SubagentStartedPayload { + /// ID of the node that spawned the subagent + pub node_id: String, + /// Unique ID of this spawn; matches the finished event + pub tool_use_id: String, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Goal assigned to the subagent + pub goal: String, + /// Full task prompt given to the subagent + pub prompt: String, + /// Subagent identifier; may be omitted + pub subagent_id: String, + /// Tools granted to the subagent; may be omitted + pub tools: Vec, +} +impl From for SubagentStartedPayload { + fn from(v: lb::SubagentStartedPayload) -> Self { + Self { + node_id: v.node_id, + tool_use_id: v.tool_use_id, + started_at: v.started_at, + goal: v.goal, + prompt: v.prompt, + subagent_id: v.subagent_id, + tools: v.tools, + } + } +} + +/// Payload of a `subagent_progress` stream event, emitted every time the +/// subagent calls one of its own tools. Use it to render a live timeline +/// inside the subagent card. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct SubagentProgressPayload { + /// ID of the node that spawned the subagent + pub node_id: String, + /// `toolUseId` of the owning `SubagentStarted` event + pub parent_tool_call_id: String, + /// Name of the tool the subagent called + pub subagent_tool_name: String, + /// Arguments of that call, as a JSON string + pub subagent_tool_args: String, + /// Status of that call: `running` / `succeeded` / `failed` + pub subagent_status: String, + /// Duration of that call in milliseconds + pub subagent_duration_ms: i64, + /// The subagent's internal round number + pub subagent_iteration: i32, + /// Start time, Unix timestamp in seconds + pub started_at: i64, +} +impl From for SubagentProgressPayload { + fn from(v: lb::SubagentProgressPayload) -> Self { + Self { + node_id: v.node_id, + parent_tool_call_id: v.parent_tool_call_id, + subagent_tool_name: v.subagent_tool_name, + subagent_tool_args: v.subagent_tool_args, + subagent_status: v.subagent_status, + subagent_duration_ms: v.subagent_duration_ms, + subagent_iteration: v.subagent_iteration, + started_at: v.started_at, + } + } +} + +/// `outputs` of a `SubagentFinishedPayload` +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct SubagentOutputs { + /// The goal that was assigned to the subagent + pub goal: Option, + /// The subagent's result + pub result: Option, + /// Timeline of tool calls the subagent made + pub subagent_tools: Option>, +} +impl From for SubagentOutputs { + fn from(v: lb::SubagentOutputs) -> Self { + Self { + goal: v.goal, + result: v.result, + subagent_tools: v.subagent_tools, + } + } +} + +/// Payload of a `subagent_finished` stream event +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct SubagentFinishedPayload { + /// ID of the node that spawned the subagent + pub node_id: String, + /// Matches the `toolUseId` of `SubagentStarted` + pub tool_use_id: String, + /// `succeeded` / `failed` + pub status: String, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Total subagent duration in seconds + pub elapsed_time: f64, + /// Error description on failure + pub error: String, + /// Subagent result: `goal`, `result`, and the timeline of tool calls it + /// made + pub outputs: SubagentOutputs, +} +impl From for SubagentFinishedPayload { + fn from(v: lb::SubagentFinishedPayload) -> Self { + Self { + node_id: v.node_id, + tool_use_id: v.tool_use_id, + status: v.status, + started_at: v.started_at, + elapsed_time: v.elapsed_time, + error: v.error, + outputs: v.outputs.into(), + } + } +} + +/// Payload of an `agent_tool_started` stream event. When the Agent delegates +/// to another Agent as a tool, that inner run is reported with the +/// `agentTool*` family — the shape mirrors the subagent events. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct AgentToolStartedPayload { + /// ID of the calling node + pub node_id: String, + /// Unique ID of this call; matches the finished event + pub tool_use_id: String, + /// Identifier of the Agent being called + pub agent_tool_name: String, + /// Display title; may be omitted + pub title: String, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Call arguments as a JSON string + pub tool_args: String, + /// Localized display name + pub tool_name: String, + /// Progress text; may be omitted + pub tips: String, + /// Short tags; may be omitted + pub tip_chips: Vec, + /// `true` if called during the thinking phase + pub is_thinking: bool, +} +impl From for AgentToolStartedPayload { + fn from(v: lb::AgentToolStartedPayload) -> Self { + Self { + node_id: v.node_id, + tool_use_id: v.tool_use_id, + agent_tool_name: v.agent_tool_name, + title: v.title, + started_at: v.started_at, + tool_args: v.tool_args, + tool_name: v.tool_name, + tips: v.tips, + tip_chips: v.tip_chips, + is_thinking: v.is_thinking, + } + } +} + +/// Payload of an `agent_tool_progress` stream event, emitted for each inner +/// tool call the delegated Agent makes. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct AgentToolProgressPayload { + /// ID of the calling node + pub node_id: String, + /// `toolUseId` of the owning `AgentToolStarted` event + pub parent_tool_call_id: String, + /// Identifier of the Agent being called + pub agent_tool_name: String, + /// Name of the inner tool the delegated Agent called + pub inner_tool_name: String, + /// Arguments of that inner call, as a JSON string + pub inner_tool_args: String, + /// Status of the inner call: `running` / `succeeded` / `failed` + pub status: String, + /// Duration of the inner call in milliseconds + pub duration_ms: i64, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// `true` if during the thinking phase + pub is_thinking: bool, +} +impl From for AgentToolProgressPayload { + fn from(v: lb::AgentToolProgressPayload) -> Self { + Self { + node_id: v.node_id, + parent_tool_call_id: v.parent_tool_call_id, + agent_tool_name: v.agent_tool_name, + inner_tool_name: v.inner_tool_name, + inner_tool_args: v.inner_tool_args, + status: v.status, + duration_ms: v.duration_ms, + started_at: v.started_at, + is_thinking: v.is_thinking, + } + } +} + +/// Payload of an `agent_tool_finished` stream event +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct AgentToolFinishedPayload { + /// ID of the calling node + pub node_id: String, + /// Matches the `toolUseId` of `AgentToolStarted` + pub tool_use_id: String, + /// Identifier of the Agent being called + pub agent_tool_name: String, + /// `succeeded` / `failed` + pub status: String, + /// Start time, Unix timestamp in seconds + pub started_at: i64, + /// Total duration in seconds + pub elapsed_time: f64, + /// Error description on failure + pub error: String, + /// Call arguments as a JSON string + pub tool_args: String, + /// Result of the delegated Agent + pub outputs: Option, + /// Tool category + pub tool_type: String, + /// Progress text; may be omitted + pub tips: String, + /// Short tags; may be omitted + pub tip_chips: Vec, + /// `true` if during the thinking phase + pub is_thinking: bool, +} +impl From for AgentToolFinishedPayload { + fn from(v: lb::AgentToolFinishedPayload) -> Self { + Self { + node_id: v.node_id, + tool_use_id: v.tool_use_id, + agent_tool_name: v.agent_tool_name, + status: v.status, + started_at: v.started_at, + elapsed_time: v.elapsed_time, + error: v.error, + tool_args: v.tool_args, + outputs: v.outputs, + tool_type: v.tool_type, + tips: v.tips, + tip_chips: v.tip_chips, + is_thinking: v.is_thinking, + } + } +} + +/// Payload of a `query_masked` stream event — sensitive content in the user +/// query was masked before processing. Display `maskedQuery` instead of the +/// original query. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct QueryMaskedPayload { + /// The original user query + pub raw_query: String, + /// The masked query + pub masked_query: String, +} +impl From for QueryMaskedPayload { + fn from(v: lb::QueryMaskedPayload) -> Self { + Self { + raw_query: v.raw_query, + masked_query: v.masked_query, + } + } +} + +/// Payload of a `plan_changed` stream event — the Agent created or updated +/// its task plan. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct PlanChangedPayload { + /// ID of the planning node + pub node_id: String, + /// Time of the change, Unix timestamp in seconds + pub started_at: i64, + /// The current plan content + pub outputs: Option, + /// Identifies the planning tool + pub tool_name: String, +} +impl From for PlanChangedPayload { + fn from(v: lb::PlanChangedPayload) -> Self { + Self { + node_id: v.node_id, + started_at: v.started_at, + outputs: v.outputs, + tool_name: v.tool_name, + } + } +} + +/// Payload of a `context_compress_started` stream event, marking the start +/// of a context-compression pass triggered by a long conversation. Unlike +/// other events, the timestamp here is an RFC 3339 string. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ContextCompressStartedPayload { + /// Start time, RFC 3339 + pub started_at: String, + /// Compression input summary + pub inputs: Option, +} +impl From for ContextCompressStartedPayload { + fn from(v: lb::ContextCompressStartedPayload) -> Self { + Self { + started_at: v.started_at, + inputs: v.inputs, + } + } +} + +/// Payload of a `context_compress_finished` stream event. Unlike other +/// events, the timestamp here is an RFC 3339 string. +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ContextCompressFinishedPayload { + /// Finish time, RFC 3339 + pub created_at: String, + /// Compression input summary + pub inputs: Option, + /// Compression result summary + pub outputs: Option, +} +impl From for ContextCompressFinishedPayload { + fn from(v: lb::ContextCompressFinishedPayload) -> Self { + Self { + created_at: v.created_at, + inputs: v.inputs, + outputs: v.outputs, + } + } +} + +/// One event observed while streaming `AgentContext.conversationStreamed` or +/// `AgentContext.continueConversationStreamed`. +/// +/// Design note: the Rust core models this as an enum with a per-variant +/// payload (`longbridge::agent::ConversationStreamEvent`), but napi-rs has no +/// ergonomic equivalent of a Rust/Serde "enum with data" for a plain +/// `#[napi(object)]` value, and there's no existing precedent for reifying one +/// as a single JS value in this codebase (the closest analogue, +/// `trade::PushEvent`, is dispatched to separate per-variant JS callbacks +/// instead). We instead mirror the common "discriminant + optional per-kind +/// fields" shape used for tagged unions in plain JS/JSON: `kind` is one of +/// `"chat_started" | "workflow_started" | "message" | "ping" | +/// "thinking_started" | "thinking_finished" | "node_tool_use_started" | +/// "node_tool_use_finished" | "subagent_started" | "subagent_progress" | +/// "subagent_finished" | "agent_tool_started" | "agent_tool_progress" | +/// "agent_tool_finished" | "human_interaction_required" | "query_masked" | +/// "plan_changed" | "context_compress_started" | "context_compress_finished" | +/// "chat_finished" | "workflow_finished" | "chat_title_updated" | "other"`, +/// and exactly one of `chatStarted` / `workflowStarted` / `message` / +/// `thinkingStarted` / `thinkingFinished` / `nodeToolUseStarted` / +/// `nodeToolUseFinished` / `subagentStarted` / `subagentProgress` / +/// `subagentFinished` / `agentToolStarted` / `agentToolProgress` / +/// `agentToolFinished` / `humanInteractionRequired` / `queryMasked` / +/// `planChanged` / `contextCompressStarted` / `contextCompressFinished` / +/// `chatFinished` / `workflowFinished` / `chatTitleUpdated` / `other` is set, +/// matching `kind` — except `"ping"`, a heartbeat with no payload, for which +/// every payload field is `None`. When `kind` is `"other"`, `otherEvent` +/// additionally carries the SSE envelope's `event` field (the event type +/// name). +#[napi_derive::napi(object)] +#[derive(Debug, Clone)] +pub struct ConversationStreamEvent { + /// Discriminant: one of `"chat_started"`, `"workflow_started"`, + /// `"message"`, `"ping"`, `"thinking_started"`, `"thinking_finished"`, + /// `"node_tool_use_started"`, `"node_tool_use_finished"`, + /// `"subagent_started"`, `"subagent_progress"`, `"subagent_finished"`, + /// `"agent_tool_started"`, `"agent_tool_progress"`, + /// `"agent_tool_finished"`, `"human_interaction_required"`, + /// `"query_masked"`, `"plan_changed"`, `"context_compress_started"`, + /// `"context_compress_finished"`, `"chat_finished"`, + /// `"workflow_finished"`, `"chat_title_updated"`, or `"other"` + pub kind: String, + /// Set when `kind` is `"chat_started"` + pub chat_started: Option, + /// Set when `kind` is `"workflow_started"` + pub workflow_started: Option, + /// Set when `kind` is `"message"` + pub message: Option, + /// Set when `kind` is `"thinking_started"` + pub thinking_started: Option, + /// Set when `kind` is `"thinking_finished"` + pub thinking_finished: Option, + /// Set when `kind` is `"node_tool_use_started"` + pub node_tool_use_started: Option, + /// Set when `kind` is `"node_tool_use_finished"` + pub node_tool_use_finished: Option, + /// Set when `kind` is `"subagent_started"` + pub subagent_started: Option, + /// Set when `kind` is `"subagent_progress"` + pub subagent_progress: Option, + /// Set when `kind` is `"subagent_finished"` + pub subagent_finished: Option, + /// Set when `kind` is `"agent_tool_started"` + pub agent_tool_started: Option, + /// Set when `kind` is `"agent_tool_progress"` + pub agent_tool_progress: Option, + /// Set when `kind` is `"agent_tool_finished"` + pub agent_tool_finished: Option, + /// Set when `kind` is `"human_interaction_required"`, carrying the run's + /// outcome for an interrupted run — the same `ConversationResponse` shape + /// `workflowFinished` carries for the other outcomes. Unlike + /// `workflowFinished`, this is set instead of (never alongside) + /// `workflowFinished` for the same run. + pub human_interaction_required: Option, + /// Set when `kind` is `"query_masked"` + pub query_masked: Option, + /// Set when `kind` is `"plan_changed"` + pub plan_changed: Option, + /// Set when `kind` is `"context_compress_started"` + pub context_compress_started: Option, + /// Set when `kind` is `"context_compress_finished"` + pub context_compress_finished: Option, + /// Set when `kind` is `"chat_finished"` + pub chat_finished: Option, + /// Set when `kind` is `"workflow_finished"`, carrying the run's outcome + /// — not necessarily the last event of the stream, since the server may + /// still emit a few more housekeeping events (`kind` `"other"`) before + /// actually closing the connection + pub workflow_finished: Option, + /// Set when `kind` is `"chat_title_updated"` + pub chat_title_updated: Option, + /// Set when `kind` is `"other"` — the SSE envelope's `event` field (the + /// event type name) + pub other_event: Option, + /// Set when `kind` is `"other"` — raw JSON of an event type not + /// recognized by this SDK version + pub other: Option, +} +impl From for ConversationStreamEvent { + fn from(v: lb::ConversationStreamEvent) -> Self { + match v { + lb::ConversationStreamEvent::ChatStarted(payload) => Self { + kind: "chat_started".to_string(), + chat_started: Some(payload.into()), + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::WorkflowStarted(payload) => Self { + kind: "workflow_started".to_string(), + chat_started: None, + workflow_started: Some(payload.into()), + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::Message(payload) => Self { + kind: "message".to_string(), + chat_started: None, + workflow_started: None, + message: Some(payload.into()), + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::Ping => Self { + kind: "ping".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::ThinkingStarted(payload) => Self { + kind: "thinking_started".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: Some(payload.into()), + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::ThinkingFinished(payload) => Self { + kind: "thinking_finished".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: Some(payload.into()), + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::NodeToolUseStarted(payload) => Self { + kind: "node_tool_use_started".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: Some(payload.into()), + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::NodeToolUseFinished(payload) => Self { + kind: "node_tool_use_finished".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: Some(payload.into()), + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::SubagentStarted(payload) => Self { + kind: "subagent_started".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: Some(payload.into()), + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::SubagentProgress(payload) => Self { + kind: "subagent_progress".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: Some(payload.into()), + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::SubagentFinished(payload) => Self { + kind: "subagent_finished".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: Some(payload.into()), + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::AgentToolStarted(payload) => Self { + kind: "agent_tool_started".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: Some(payload.into()), + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::AgentToolProgress(payload) => Self { + kind: "agent_tool_progress".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: Some(payload.into()), + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::AgentToolFinished(payload) => Self { + kind: "agent_tool_finished".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: Some(payload.into()), + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::HumanInteractionRequired(resp) => Self { + kind: "human_interaction_required".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: Some(resp.into()), + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::QueryMasked(payload) => Self { + kind: "query_masked".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: Some(payload.into()), + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::PlanChanged(payload) => Self { + kind: "plan_changed".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: Some(payload.into()), + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::ContextCompressStarted(payload) => Self { + kind: "context_compress_started".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: Some(payload.into()), + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::ContextCompressFinished(payload) => Self { + kind: "context_compress_finished".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: Some(payload.into()), + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::ChatFinished(payload) => Self { + kind: "chat_finished".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: Some(payload.into()), + workflow_finished: None, + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::WorkflowFinished(resp) => Self { + kind: "workflow_finished".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: Some(resp.into()), + chat_title_updated: None, + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::ChatTitleUpdated(payload) => Self { + kind: "chat_title_updated".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: Some(payload.into()), + other_event: None, + other: None, + }, + lb::ConversationStreamEvent::Other { event, data } => Self { + kind: "other".to_string(), + chat_started: None, + workflow_started: None, + message: None, + thinking_started: None, + thinking_finished: None, + node_tool_use_started: None, + node_tool_use_finished: None, + subagent_started: None, + subagent_progress: None, + subagent_finished: None, + agent_tool_started: None, + agent_tool_progress: None, + agent_tool_finished: None, + human_interaction_required: None, + query_masked: None, + plan_changed: None, + context_compress_started: None, + context_compress_finished: None, + chat_finished: None, + workflow_finished: None, + chat_title_updated: None, + other_event: Some(event), + other: Some(data), + }, + } + } +} diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs index 7bbacd5be3..0846d45a39 100644 --- a/nodejs/src/lib.rs +++ b/nodejs/src/lib.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +mod agent; mod alert; mod asset; mod calendar; diff --git a/python/Cargo.toml b/python/Cargo.toml index f3c090bf78..ec89e51618 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -19,6 +19,7 @@ longbridge = { workspace = true, features = ["blocking"] } longbridge-python-macros = { path = "crates/macros" } pyo3-async-runtimes = { workspace = true } +futures-util.workspace = true parking_lot.workspace = true pyo3 = { workspace = true, features = [ "extension-module", diff --git a/python/pysrc/longbridge/openapi.pyi b/python/pysrc/longbridge/openapi.pyi index 6596ecdedb..b1663084fc 100644 --- a/python/pysrc/longbridge/openapi.pyi +++ b/python/pysrc/longbridge/openapi.pyi @@ -1,6 +1,16 @@ from datetime import date, datetime, time from decimal import Decimal -from typing import Any, Awaitable, Callable, Coroutine, List, Optional, Type +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Coroutine, + Iterator, + List, + Optional, + Type, +) class ErrorKind: """ @@ -13097,3 +13107,984 @@ class USETFFilesResponse: files: List[USETFFile] """Document entries""" + +# ── AI Agent ────────────────────────────────────────────────────── + +class Workspace: + """A Workspace the current account belongs to.""" + + id: str + """Workspace ID""" + name: str + """Workspace name""" + created_at: int + """Creation time, Unix timestamp in seconds""" + updated_at: int + """Last updated time, Unix timestamp in seconds""" + +class WorkspacesResponse: + """Response for AgentContext.workspaces / AsyncAgentContext.workspaces.""" + + workspaces: list[Workspace] + """Workspaces the current account belongs to""" + +class Agent: + """An Agent in a Workspace.""" + + uid: str + """Agent UID, used as the ``agent_id`` argument of + AgentContext.conversation""" + name: str + """Agent name""" + description: str + """Agent description""" + mode: str + """Agent mode, e.g. ``"chat"``""" + icon: str + """Icon URL""" + is_published: bool + """Whether published; only published Agents can start conversations""" + published_at: int + """Publish time, Unix timestamp in seconds; 0 if unpublished""" + created_at: int + """Creation time, Unix timestamp in seconds""" + updated_at: int + """Last updated time, Unix timestamp in seconds""" + +class AgentsResponse: + """Response for AgentContext.agents / AsyncAgentContext.agents.""" + + agents: list[Agent] + """Agent list""" + total: int + """Total number of matching Agents""" + +class ConversationStatus: + """Final run status of a conversation.""" + + class Succeeded(ConversationStatus): + """The run completed successfully""" + + ... + + class Interrupted(ConversationStatus): + """The run is paused, waiting for AgentContext.continue_conversation""" + + ... + + class Failed(ConversationStatus): + """The run failed""" + + ... + + class Stopped(ConversationStatus): + """The run was stopped""" + + ... + +class Reference: + """A source referenced by the answer.""" + + index: int + """Reference index""" + title: str + """Reference title""" + url: str + """Reference URL""" + +class QuestionOption: + """One option of a Question.""" + + description: str + """Option text""" + +class Question: + """One question the Agent needs you to answer.""" + + question: str + """Question text""" + options: list[QuestionOption] + """Options; empty means free-form answer""" + multi_select: bool + """Whether multiple options may be selected""" + +class Interrupt: + """Present when a conversation run is interrupted, waiting for + AgentContext.continue_conversation.""" + + node_id: str + """ID of the node that triggered the interrupt""" + tool_call_id: str + """Tool call ID of this inquiry; used as the answer key when continuing""" + questions: list[Question] + """Questions you need to answer""" + message_id: int + """ID of the paused message""" + chat_id: int + """ID of the owning conversation""" + +class AgentError: + """Present when a conversation run failed.""" + + code: int + """Error code""" + message: str + """Error message""" + +class ConversationResponse: + """ + Response for AgentContext.conversation / AgentContext.continue_conversation, + and the final result of the streamed counterparts. + """ + + chat_uid: str + """Conversation identifier, used for follow-up questions and + troubleshooting""" + message_id: str + """Message ID of this round""" + status: ConversationStatus + """Final run status""" + answer: str + """Final answer text; valid when status is ConversationStatus.Succeeded""" + references: list[Reference] | None + """Sources referenced by the answer""" + elapsed_time: float + """Run duration in seconds""" + interrupt: Interrupt | None + """Present only when status is ConversationStatus.Interrupted""" + error: AgentError | None + """Present only when the run failed""" + +class ChatStartedPayload: + """Payload of a ``chat_started`` stream event.""" + + chat_uid: str + """Conversation identifier""" + message_id: str + """Message ID of this round""" + +class MessagePayload: + """ + Payload of a ``message`` stream event — an incremental text chunk. This + is the highest-frequency event; concatenate ``text`` fragments in + arrival order. + """ + + text: str + """Incremental text fragment""" + message_type: str + """``"answer"`` — final answer text; ``"think"`` — reasoning process; + ``"process"`` — stage progress description""" + key: str + """Identifier of the stream segment this fragment belongs to. Fragments + with the same key form one continuous block — group by key when + rendering""" + started_at: int + """Time this segment started, Unix timestamp in seconds""" + stage: str + """Stage identifier; only present when message_type is ``"process"``""" + stage_title: str + """Stage title while running; only present when message_type is + ``"process"``""" + stage_finished_title: str + """Stage title after it finishes; only present when message_type is + ``"process"``""" + outputs: Any | None + """Extra payload attached to the fragment; usually absent""" + +class WorkflowStartedInputs: + """ + ``inputs`` of a ``workflow_started`` stream event. + """ + + chat_id: int + """ID of the owning conversation""" + chat_uid: str + """Conversation identifier""" + message_id: str + """Message ID of this round""" + query: str + """The question that was asked""" + +class WorkflowStartedPayload: + """ + Payload of a ``workflow_started`` stream event, observed right after + ``chat_started``. + """ + + hit_cache: bool + """Whether this run's answer was served from a cache""" + inputs: WorkflowStartedInputs + """Echoes the run's inputs""" + started_at: int + """Unix timestamp in seconds""" + workflow_id: int + """Internal workflow run ID""" + +class ChatFinishedPayload: + """ + Payload of a ``chat_finished`` stream event, observed once all + ``message`` events for this round have been sent, shortly before + ``workflow_finished``. + """ + + chat_id: int + """ID of the owning conversation""" + chat_uid: str + """Conversation identifier""" + message_id: str + """Message ID of this round""" + error: str + """Empty string in every run observed so far""" + error_message: str + """Empty string in every run observed so far""" + +class ChatTitleUpdatedPayload: + """ + Payload of a ``chat_title_updated`` stream event — the server + auto-generates a short title for the conversation as a UI convenience. + Can arrive before *or* after ``workflow_finished``; not tied to the run's + outcome. + """ + + chat_id: int + """ID of the owning conversation""" + chat_uid: str + """Conversation identifier""" + source: str + """Where the title came from, e.g. ``"ai_generated"``""" + title: str + """The new (possibly truncated) title""" + updated_at: int + """Unix timestamp in seconds""" + +class ThinkingStartedPayload: + """ + Payload of a ``thinking_started`` stream event — the Agent has entered + the reasoning phase (analyzing the question, planning tool calls). + Between this and ``thinking_finished``, ``message`` events with + ``message_type == "think"`` and tool-call events may arrive. + """ + + started_at: int + """Start time, Unix timestamp in seconds""" + +class ThinkingFinishedPayload: + """ + Payload of a ``thinking_finished`` stream event — the reasoning phase is + over; answer text (``message`` with ``message_type == "answer"``) + follows. + """ + + finished_at: int + """Finish time, Unix timestamp in seconds""" + elapsed_time: int + """Reasoning duration in seconds""" + +class NodeToolUseStartedPayload: + """ + Payload of a ``node_tool_use_started`` stream event — an ordinary tool + call has started. Match it to its ``node_tool_use_finished`` counterpart + by ``tool_use_id``. + """ + + tool_use_id: str + """Unique ID of this call; matches the finished event""" + tool_name: str + """Localized display name of the tool""" + tool_func_name: str + """Locale-stable tool identifier; use this for logic keyed on the tool + kind""" + tool_args: str + """Call arguments as a JSON string""" + tips: str + """Progress text suitable for direct display, e.g. ``"Searching the + web..."``""" + tip_chips: list[str] + """Short tags accompanying tips; may be empty""" + iteration: int + """Round number. Calls in the same round (same iteration) run in + parallel""" + started_at: int + """Start time, Unix timestamp in seconds""" + +class NodeToolUseOutputs: + """``outputs`` of a NodeToolUseFinishedPayload — only carries fields + meant for display.""" + + references: list[Reference] | None + """Sources referenced by the tool result""" + reference_domains: list[str] | None + """Domains of the referenced sources""" + query: str | None + """The query the tool executed""" + text: str | None + """Raw response text of the tool""" + tool_args: Any | None + """Parsed request arguments""" + data: Any | None + """Structured result; present only for selected tools""" + +class NodeToolUseFinishedPayload: + """Payload of a ``node_tool_use_finished`` stream event — the tool call + has ended.""" + + tool_use_id: str + """Matches the tool_use_id of the started event""" + status: str + """``"succeeded"`` / ``"failed"``""" + error: str + """Error description on failure""" + elapsed_time: float + """Call duration in seconds""" + started_at: int + """Start time, Unix timestamp in seconds""" + tool_name: str + """Localized display name""" + tool_func_name: str + """Locale-stable tool identifier""" + tool_args: str + """Call arguments as a JSON string""" + tool_type: str + """Tool category""" + tips: str + """Progress text""" + tip_chips: list[str] + """Short tags; may be empty""" + iteration: int + """Round number""" + is_thinking: bool + """True if the call happened during the thinking phase""" + outputs: NodeToolUseOutputs + """Filtered call results, for display""" + +class SubagentStartedPayload: + """ + Payload of a ``subagent_started`` stream event. When the Agent spawns a + subagent to work on a sub-task, the subagent's lifecycle is reported + with this dedicated event family instead of ``node_tool_use_*``. + """ + + node_id: str + """ID of the node that spawned the subagent""" + tool_use_id: str + """Unique ID of this spawn; matches the finished event""" + started_at: int + """Start time, Unix timestamp in seconds""" + goal: str + """Goal assigned to the subagent""" + prompt: str + """Full task prompt given to the subagent""" + subagent_id: str + """Subagent identifier; may be empty""" + tools: list[Any] + """Tools granted to the subagent; may be empty""" + +class SubagentProgressPayload: + """ + Payload of a ``subagent_progress`` stream event, emitted every time the + subagent calls one of its own tools. Use it to render a live timeline + inside the subagent card. + """ + + node_id: str + """ID of the node that spawned the subagent""" + parent_tool_call_id: str + """tool_use_id of the owning SubagentStarted event""" + subagent_tool_name: str + """Name of the tool the subagent called""" + subagent_tool_args: str + """Arguments of that call, as a JSON string""" + subagent_status: str + """Status of that call: ``"running"`` / ``"succeeded"`` / ``"failed"``""" + subagent_duration_ms: int + """Duration of that call in milliseconds""" + subagent_iteration: int + """The subagent's internal round number""" + started_at: int + """Start time, Unix timestamp in seconds""" + +class SubagentOutputs: + """``outputs`` of a SubagentFinishedPayload.""" + + goal: str | None + """The goal that was assigned to the subagent""" + result: str | None + """The subagent's result""" + subagent_tools: list[Any] | None + """Timeline of tool calls the subagent made""" + +class SubagentFinishedPayload: + """Payload of a ``subagent_finished`` stream event.""" + + node_id: str + """ID of the node that spawned the subagent""" + tool_use_id: str + """Matches the tool_use_id of SubagentStarted""" + status: str + """``"succeeded"`` / ``"failed"``""" + started_at: int + """Start time, Unix timestamp in seconds""" + elapsed_time: float + """Total subagent duration in seconds""" + error: str + """Error description on failure""" + outputs: SubagentOutputs + """Subagent result: goal, result, and the timeline of tool calls it + made""" + +class AgentToolStartedPayload: + """ + Payload of an ``agent_tool_started`` stream event. When the Agent + delegates to another Agent as a tool, that inner run is reported with + the ``agent_tool_*`` family — the shape mirrors the subagent events. + """ + + node_id: str + """ID of the calling node""" + tool_use_id: str + """Unique ID of this call; matches the finished event""" + agent_tool_name: str + """Identifier of the Agent being called""" + title: str + """Display title; may be empty""" + started_at: int + """Start time, Unix timestamp in seconds""" + tool_args: str + """Call arguments as a JSON string""" + tool_name: str + """Localized display name""" + tips: str + """Progress text; may be empty""" + tip_chips: list[str] + """Short tags; may be empty""" + is_thinking: bool + """True if called during the thinking phase""" + +class AgentToolProgressPayload: + """ + Payload of an ``agent_tool_progress`` stream event, emitted for each + inner tool call the delegated Agent makes. + """ + + node_id: str + """ID of the calling node""" + parent_tool_call_id: str + """tool_use_id of the owning AgentToolStarted event""" + agent_tool_name: str + """Identifier of the Agent being called""" + inner_tool_name: str + """Name of the inner tool the delegated Agent called""" + inner_tool_args: str + """Arguments of that inner call, as a JSON string""" + status: str + """Status of the inner call: ``"running"`` / ``"succeeded"`` / + ``"failed"``""" + duration_ms: int + """Duration of the inner call in milliseconds""" + started_at: int + """Start time, Unix timestamp in seconds""" + is_thinking: bool + """True if during the thinking phase""" + +class AgentToolFinishedPayload: + """Payload of an ``agent_tool_finished`` stream event.""" + + node_id: str + """ID of the calling node""" + tool_use_id: str + """Matches the tool_use_id of AgentToolStarted""" + agent_tool_name: str + """Identifier of the Agent being called""" + status: str + """``"succeeded"`` / ``"failed"``""" + started_at: int + """Start time, Unix timestamp in seconds""" + elapsed_time: float + """Total duration in seconds""" + error: str + """Error description on failure""" + tool_args: str + """Call arguments as a JSON string""" + outputs: Any | None + """Result of the delegated Agent""" + tool_type: str + """Tool category""" + tips: str + """Progress text; may be empty""" + tip_chips: list[str] + """Short tags; may be empty""" + is_thinking: bool + """True if during the thinking phase""" + +class QueryMaskedPayload: + """ + Payload of a ``query_masked`` stream event — sensitive content in the + user query was masked before processing. Display masked_query instead + of the original query. + """ + + raw_query: str + """The original user query""" + masked_query: str + """The masked query""" + +class PlanChangedPayload: + """Payload of a ``plan_changed`` stream event — the Agent created or + updated its task plan.""" + + node_id: str + """ID of the planning node""" + started_at: int + """Time of the change, Unix timestamp in seconds""" + outputs: Any | None + """The current plan content""" + tool_name: str + """Identifies the planning tool""" + +class ContextCompressStartedPayload: + """ + Payload of a ``context_compress_started`` stream event, marking the + start of a context-compression pass triggered by a long conversation. + Unlike other events, the timestamp here is an RFC 3339 string. + """ + + started_at: str + """Start time, RFC 3339""" + inputs: Any | None + """Compression input summary""" + +class ContextCompressFinishedPayload: + """ + Payload of a ``context_compress_finished`` stream event. Unlike other + events, the timestamp here is an RFC 3339 string. + """ + + created_at: str + """Finish time, RFC 3339""" + inputs: Any | None + """Compression input summary""" + outputs: Any | None + """Compression result summary""" + +class ConversationStreamEvent: + """ + One event observed while streaming + AgentContext.conversation_streamed / continue_conversation_streamed (or + the Async counterparts). + + A run always begins with ``kind == "chat_started"`` and ends with + ``kind == "chat_finished"``. What happens in between depends on the + outcome: + + - Succeeded: ``chat_started`` -> ``workflow_started`` -> + ``thinking_started`` -> ``message`` (message_type == "think") ... -> + ``node_tool_use_started`` / ``node_tool_use_finished`` ... -> + ``thinking_finished`` -> ``message`` (message_type == "answer") ... -> + ``workflow_finished`` (status == "succeeded") -> ``chat_finished`` + - Interrupted (the Agent needs your input; resume via + ``continue_conversation_streamed``): ``chat_started`` -> + ``workflow_started`` -> ... -> ``human_interaction_required`` -> + ``chat_finished``. An interrupted run does **not** emit + ``workflow_finished``, and resuming it does **not** emit + ``workflow_started`` again. + - Failed: ``chat_started`` -> ``workflow_started`` -> ... -> + ``workflow_finished`` (status == "failed") -> ``chat_finished`` + + ``kind`` is the discriminant — one of ``"chat_started"``, + ``"workflow_started"``, ``"message"``, ``"ping"``, + ``"thinking_started"``, ``"thinking_finished"``, + ``"node_tool_use_started"``, ``"node_tool_use_finished"``, + ``"subagent_started"``, ``"subagent_progress"``, ``"subagent_finished"``, + ``"agent_tool_started"``, ``"agent_tool_progress"``, + ``"agent_tool_finished"``, ``"human_interaction_required"``, + ``"query_masked"``, ``"plan_changed"``, ``"context_compress_started"``, + ``"context_compress_finished"``, ``"chat_finished"``, + ``"workflow_finished"``, ``"chat_title_updated"``, ``"other"`` — and + exactly one of the payload fields below sharing that name is set, + matching ``kind`` — except ``"ping"``, a heartbeat with no payload, for + which every payload field is ``None``. + + For a plain question-and-answer integration you only need to handle + four kinds — everything else is optional progress display: + ``"message"`` with ``message_type == "answer"`` (append ``text`` to the + answer being displayed), ``"human_interaction_required"`` (show the + questions and call ``continue_conversation`` / + ``continue_conversation_streamed`` with the answers), + ``"workflow_finished"`` (read the final outcome), and + ``"chat_finished"`` (the stream is over). + """ + + kind: str + """Discriminant: ``"chat_started"``, ``"workflow_started"``, + ``"message"``, ``"ping"``, ``"thinking_started"``, + ``"thinking_finished"``, ``"node_tool_use_started"``, + ``"node_tool_use_finished"``, ``"subagent_started"``, + ``"subagent_progress"``, ``"subagent_finished"``, + ``"agent_tool_started"``, ``"agent_tool_progress"``, + ``"agent_tool_finished"``, ``"human_interaction_required"``, + ``"query_masked"``, ``"plan_changed"``, ``"context_compress_started"``, + ``"context_compress_finished"``, ``"chat_finished"``, + ``"workflow_finished"``, ``"chat_title_updated"``, or ``"other"``""" + chat_started: ChatStartedPayload | None + """Set when kind == "chat_started" """ + workflow_started: WorkflowStartedPayload | None + """Set when kind == "workflow_started". Observed right after + chat_started on every run seen so far. Not emitted when resuming an + interrupted run.""" + message: MessagePayload | None + """Set when kind == "message": an incremental piece of the answer""" + thinking_started: ThinkingStartedPayload | None + """Set when kind == "thinking_started": the Agent has entered the + reasoning phase""" + thinking_finished: ThinkingFinishedPayload | None + """Set when kind == "thinking_finished": the reasoning phase is over""" + node_tool_use_started: NodeToolUseStartedPayload | None + """Set when kind == "node_tool_use_started": an ordinary tool call has + started""" + node_tool_use_finished: NodeToolUseFinishedPayload | None + """Set when kind == "node_tool_use_finished": an ordinary tool call has + ended""" + subagent_started: SubagentStartedPayload | None + """Set when kind == "subagent_started": the Agent has spawned a + subagent to work on a sub-task""" + subagent_progress: SubagentProgressPayload | None + """Set when kind == "subagent_progress": the subagent has called one of + its own tools""" + subagent_finished: SubagentFinishedPayload | None + """Set when kind == "subagent_finished": the subagent has finished its + sub-task""" + agent_tool_started: AgentToolStartedPayload | None + """Set when kind == "agent_tool_started": the Agent has delegated to + another Agent as a tool""" + agent_tool_progress: AgentToolProgressPayload | None + """Set when kind == "agent_tool_progress": the delegated Agent has + called one of its own tools""" + agent_tool_finished: AgentToolFinishedPayload | None + """Set when kind == "agent_tool_finished": the delegated Agent's run has + finished""" + human_interaction_required: ConversationResponse | None + """Set when kind == "human_interaction_required": the run is paused — + the Agent needs more information or confirmation from you. Carries the + same ConversationResponse shape as workflow_finished (status == + ConversationStatus.Interrupted, with interrupt set) — an interrupted + run never emits workflow_finished at all, so this is the terminal event + carrying the run's outcome for that case instead. Resume via + continue_conversation / continue_conversation_streamed using + ConversationResponse.interrupt.""" + query_masked: QueryMaskedPayload | None + """Set when kind == "query_masked": sensitive content in the user query + was masked before processing""" + plan_changed: PlanChangedPayload | None + """Set when kind == "plan_changed": the Agent created or updated its + task plan""" + context_compress_started: ContextCompressStartedPayload | None + """Set when kind == "context_compress_started": a context-compression + pass has started (long conversations trigger this)""" + context_compress_finished: ContextCompressFinishedPayload | None + """Set when kind == "context_compress_finished": the context-compression + pass has finished""" + chat_finished: ChatFinishedPayload | None + """Set when kind == "chat_finished" """ + workflow_finished: ConversationResponse | None + """Set when kind == "workflow_finished" (the run finished — succeeded or + failed; never emitted for an interrupted run, see + human_interaction_required). Carries the run's outcome, but isn't + necessarily the last event of the stream — the server may still emit a + few more housekeeping events (e.g. kind == "chat_title_updated") before + actually closing the connection.""" + chat_title_updated: ChatTitleUpdatedPayload | None + """Set when kind == "chat_title_updated" """ + other_event: str | None + """Set when kind == "other": the SSE envelope's ``event`` field (the event + type name)""" + other: Any | None + """Set when kind == "other": raw JSON payload of an event type not + recognized by this SDK version""" + +class ConversationStreamIter: + """ + Blocking iterator of ConversationStreamEvent, returned by + AgentContext.conversation_streamed / continue_conversation_streamed. Use + with a plain ``for`` loop. + """ + + def __iter__(self) -> "ConversationStreamIter": ... + def __next__(self) -> ConversationStreamEvent: ... + +class AgentContext: + """ + AI Agent conversation context. + + Examples: + :: + + from longbridge.openapi import Config, AgentContext + + config = Config.from_env() + ctx = AgentContext(config) + + workspaces = ctx.workspaces() + agents = ctx.agents(workspaces.workspaces[0].id) + resp = ctx.conversation( + agents.agents[0].uid, "How has Tesla stock performed recently?" + ) + print(resp) + + for event in ctx.conversation_streamed( + agents.agents[0].uid, "How has Tesla stock performed recently?" + ): + print(event) + """ + + def __init__(self, config: "Config") -> None: + """Create an AgentContext.""" + ... + + def workspaces(self) -> WorkspacesResponse: + """List the Workspaces the current account belongs to.""" + ... + + def agents( + self, + workspace_id: str, + page: int | None = None, + limit: int | None = None, + name: str | None = None, + ) -> AgentsResponse: + """ + List the Agents in the specified Workspace. + + Args: + workspace_id: Workspace ID + page: Page number, starts at 1 + limit: Page size + name: Fuzzy search by Agent name + """ + ... + + def conversation( + self, agent_id: str, query: str, chat_uid: str | None = None + ) -> ConversationResponse: + """ + Start a conversation with the specified Agent, blocking until the run + succeeds, is interrupted, or fails. + + Args: + agent_id: Agent UID + query: The question to ask + chat_uid: Continue an existing conversation instead of starting a + new one + """ + ... + + def continue_conversation( + self, + agent_id: str, + chat_uid: str, + message_id: str, + answers_by_tool_call: dict[str, dict[str, str]], + ) -> ConversationResponse: + """ + Resume an interrupted conversation, blocking until the run succeeds, + is interrupted again, or fails. + + Args: + agent_id: Agent UID + chat_uid: Conversation identifier, from ConversationResponse.chat_uid + message_id: ID of the paused message, from + ConversationResponse.message_id + answers_by_tool_call: Answers keyed by ``tool_call_id`` (from + ConversationResponse.interrupt), each value a map of question + text to answer + """ + ... + + def conversation_streamed( + self, agent_id: str, query: str, chat_uid: str | None = None + ) -> Iterator[ConversationStreamEvent]: + """ + Start a conversation with the specified Agent, returning an iterator + of run-progress events. A ConversationStreamEvent with + ``kind == "workflow_finished"`` carries the run's outcome, but isn't + necessarily the last item — the server may still emit a few more + housekeeping events (e.g. ``kind == "chat_title_updated"``) before + actually closing the connection, so keep iterating to the end rather + than stopping as soon as you see it. + + Args: + agent_id: Agent UID + query: The question to ask + chat_uid: Continue an existing conversation instead of starting a + new one + """ + ... + + def continue_conversation_streamed( + self, + agent_id: str, + chat_uid: str, + message_id: str, + answers_by_tool_call: dict[str, dict[str, str]], + ) -> Iterator[ConversationStreamEvent]: + """ + Resume an interrupted conversation, returning an iterator of + run-progress events. + + Args: + agent_id: Agent UID + chat_uid: Conversation identifier, from ConversationResponse.chat_uid + message_id: ID of the paused message, from + ConversationResponse.message_id + answers_by_tool_call: Answers keyed by ``tool_call_id`` (from + ConversationResponse.interrupt), each value a map of question + text to answer + """ + ... + +class AsyncConversationStreamIter: + """ + Async iterator of ConversationStreamEvent, returned (once awaited) by + AsyncAgentContext.conversation_streamed / continue_conversation_streamed. + Use with ``async for``. + """ + + def __aiter__(self) -> "AsyncConversationStreamIter": ... + async def __anext__(self) -> ConversationStreamEvent: ... + +class AsyncAgentContext: + """ + Async AI Agent conversation context for use with asyncio. Create via + AsyncAgentContext.create(config); all I/O methods return awaitables. + + Examples: + :: + + import asyncio + from longbridge.openapi import Config, AsyncAgentContext + + async def main(): + config = Config.from_env() + ctx = AsyncAgentContext.create(config) + + workspaces = await ctx.workspaces() + agents = await ctx.agents(workspaces.workspaces[0].id) + resp = await ctx.conversation( + agents.agents[0].uid, "How has Tesla stock performed recently?" + ) + print(resp) + + stream = await ctx.conversation_streamed( + agents.agents[0].uid, "How has Tesla stock performed recently?" + ) + async for event in stream: + print(event) + + asyncio.run(main()) + """ + + @classmethod + def create( + cls: Type[AsyncAgentContext], config: Config + ) -> AsyncAgentContext: + """Create an async AI Agent context.""" + ... + + def workspaces(self) -> Awaitable[WorkspacesResponse]: + """List the Workspaces the current account belongs to. Returns + awaitable.""" + ... + + def agents( + self, + workspace_id: str, + page: int | None = None, + limit: int | None = None, + name: str | None = None, + ) -> Awaitable[AgentsResponse]: + """ + List the Agents in the specified Workspace. Returns awaitable. + + Args: + workspace_id: Workspace ID + page: Page number, starts at 1 + limit: Page size + name: Fuzzy search by Agent name + """ + ... + + def conversation( + self, agent_id: str, query: str, chat_uid: str | None = None + ) -> Awaitable[ConversationResponse]: + """ + Start a conversation with the specified Agent, blocking until the run + succeeds, is interrupted, or fails. Returns awaitable. + + Args: + agent_id: Agent UID + query: The question to ask + chat_uid: Continue an existing conversation instead of starting a + new one + """ + ... + + def continue_conversation( + self, + agent_id: str, + chat_uid: str, + message_id: str, + answers_by_tool_call: dict[str, dict[str, str]], + ) -> Awaitable[ConversationResponse]: + """ + Resume an interrupted conversation, blocking until the run succeeds, + is interrupted again, or fails. Returns awaitable. + + Args: + agent_id: Agent UID + chat_uid: Conversation identifier, from ConversationResponse.chat_uid + message_id: ID of the paused message, from + ConversationResponse.message_id + answers_by_tool_call: Answers keyed by ``tool_call_id`` (from + ConversationResponse.interrupt), each value a map of question + text to answer + """ + ... + + def conversation_streamed( + self, agent_id: str, query: str, chat_uid: str | None = None + ) -> Awaitable[AsyncConversationStreamIter]: + """ + Start a conversation with the specified Agent. Returns an awaitable + that resolves to an AsyncConversationStreamIter; use ``async for`` on + it to consume run-progress events. + + Args: + agent_id: Agent UID + query: The question to ask + chat_uid: Continue an existing conversation instead of starting a + new one + """ + ... + + def continue_conversation_streamed( + self, + agent_id: str, + chat_uid: str, + message_id: str, + answers_by_tool_call: dict[str, dict[str, str]], + ) -> Awaitable[AsyncConversationStreamIter]: + """ + Resume an interrupted conversation. Returns an awaitable that resolves + to an AsyncConversationStreamIter; use ``async for`` on it to consume + run-progress events. + + Args: + agent_id: Agent UID + chat_uid: Conversation identifier, from ConversationResponse.chat_uid + message_id: ID of the paused message, from + ConversationResponse.message_id + answers_by_tool_call: Answers keyed by ``tool_call_id`` (from + ConversationResponse.interrupt), each value a map of question + text to answer + """ + ... diff --git a/python/src/agent/context.rs b/python/src/agent/context.rs new file mode 100644 index 0000000000..6d264170e7 --- /dev/null +++ b/python/src/agent/context.rs @@ -0,0 +1,173 @@ +use std::{collections::HashMap, sync::Arc}; + +use longbridge::{ + agent::{self, GetAgentsOptions}, + blocking::AgentContextSync, +}; +use parking_lot::Mutex; +use pyo3::prelude::*; + +use crate::{ + agent::types::{ + AgentsResponse, ConversationResponse, ConversationStreamEvent, WorkspacesResponse, + }, + config::Config, + error::ErrorNewType, +}; + +/// AI Agent conversation context. +#[pyclass] +pub(crate) struct AgentContext(AgentContextSync); + +#[pymethods] +impl AgentContext { + #[new] + fn new(config: &Config) -> PyResult { + Ok(Self( + AgentContextSync::new(Arc::new(config.0.clone())).map_err(ErrorNewType)?, + )) + } + + /// List the Workspaces the current account belongs to. + fn workspaces(&self, py: Python<'_>) -> PyResult { + Ok(py + .detach(|| self.0.workspaces()) + .map_err(ErrorNewType)? + .into()) + } + + /// List the Agents in the specified Workspace. + #[pyo3(signature = (workspace_id, page = None, limit = None, name = None))] + fn agents( + &self, + py: Python<'_>, + workspace_id: String, + page: Option, + limit: Option, + name: Option, + ) -> PyResult { + let mut opts = GetAgentsOptions::new(); + if let Some(page) = page { + opts = opts.page(page); + } + if let Some(limit) = limit { + opts = opts.limit(limit); + } + if let Some(name) = name { + opts = opts.name(name); + } + + Ok(py + .detach(|| self.0.agents(workspace_id, Some(opts))) + .map_err(ErrorNewType)? + .into()) + } + + /// Start a conversation with the specified Agent, blocking until the run + /// succeeds, is interrupted, or fails. + #[pyo3(signature = (agent_id, query, chat_uid = None))] + fn conversation( + &self, + py: Python<'_>, + agent_id: String, + query: String, + chat_uid: Option, + ) -> PyResult { + Ok(py + .detach(|| self.0.conversation(agent_id, query, chat_uid)) + .map_err(ErrorNewType)? + .into()) + } + + /// Resume an interrupted conversation, blocking until the run succeeds, is + /// interrupted again, or fails. + /// + /// `answers_by_tool_call` maps `tool_call_id` (from + /// `ConversationResponse.interrupt`) to a map of question text to answer. + fn continue_conversation( + &self, + py: Python<'_>, + agent_id: String, + chat_uid: String, + message_id: String, + answers_by_tool_call: HashMap>, + ) -> PyResult { + Ok(py + .detach(|| { + self.0 + .continue_conversation(agent_id, chat_uid, message_id, answers_by_tool_call) + }) + .map_err(ErrorNewType)? + .into()) + } + + /// Start a conversation with the specified Agent, returning an iterator of + /// run-progress events. A `ConversationStreamEvent` with + /// `kind == "workflow_finished"` carries the run's outcome, but isn't + /// necessarily the last item — the server may still emit a few more + /// housekeeping events (e.g. `kind == "chat_title_updated"`) before + /// actually closing the connection, so keep iterating to the end rather + /// than stopping as soon as you see it. + #[pyo3(signature = (agent_id, query, chat_uid = None))] + fn conversation_streamed( + &self, + py: Python<'_>, + agent_id: String, + query: String, + chat_uid: Option, + ) -> PyResult { + let iter = py + .detach(|| self.0.conversation_streamed(agent_id, query, chat_uid)) + .map_err(ErrorNewType)?; + Ok(ConversationStreamIter(Mutex::new(iter))) + } + + /// Resume an interrupted conversation, returning an iterator of + /// run-progress events. + fn continue_conversation_streamed( + &self, + py: Python<'_>, + agent_id: String, + chat_uid: String, + message_id: String, + answers_by_tool_call: HashMap>, + ) -> PyResult { + let iter = py + .detach(|| { + self.0.continue_conversation_streamed( + agent_id, + chat_uid, + message_id, + answers_by_tool_call, + ) + }) + .map_err(ErrorNewType)?; + Ok(ConversationStreamIter(Mutex::new(iter))) + } +} + +/// Blocking iterator of conversation-stream events, returned by +/// `AgentContext.conversation_streamed`/`continue_conversation_streamed`. Use +/// with a plain `for` loop. +/// +/// Wrapped in a `Mutex` (rather than exposed as a bare +/// `agent::ConversationStreamIter`) because `#[pyclass]` requires its wrapped +/// type to be `Send + Sync`, and the inner `std::sync::mpsc::Receiver` is +/// `Send` but not `Sync`. +#[pyclass] +pub(crate) struct ConversationStreamIter(Mutex); + +#[pymethods] +impl ConversationStreamIter { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(&self, py: Python<'_>) -> PyResult> { + match py.detach(|| self.0.lock().next()) { + Some(Ok(event)) => Ok(Some(event.into())), + Some(Err(err)) => Err(ErrorNewType(err).into()), + None => Ok(None), + } + } +} diff --git a/python/src/agent/context_async.rs b/python/src/agent/context_async.rs new file mode 100644 index 0000000000..e81d5384b7 --- /dev/null +++ b/python/src/agent/context_async.rs @@ -0,0 +1,224 @@ +//! Async AI Agent context backed by longbridge's native async API. + +use std::{collections::HashMap, pin::Pin, sync::Arc}; + +use futures_util::{Stream, StreamExt}; +use longbridge::agent::{ + AgentContext, ConversationStreamEvent as RustConversationStreamEvent, GetAgentsOptions, +}; +use pyo3::{exceptions::PyStopAsyncIteration, prelude::*, types::PyType}; +use tokio::sync::Mutex; + +use crate::{ + agent::types::{ + AgentsResponse, ConversationResponse, ConversationStreamEvent, WorkspacesResponse, + }, + config::Config, + error::ErrorNewType, +}; + +/// A boxed conversation-stream event stream, stored behind a `tokio::Mutex` so +/// `AsyncConversationStreamIter::__anext__` (which only gets `&self`, per the +/// async-iterator protocol) can still drive it one item at a time. +type BoxedEventStream = + Pin> + Send>>; + +/// Async AI Agent context. Create via `AsyncAgentContext.create(config)` +/// (synchronous, no await needed). Use in asyncio. +#[pyclass] +pub(crate) struct AsyncAgentContext { + ctx: Arc, +} + +#[pymethods] +impl AsyncAgentContext { + /// Create an async AI Agent context (synchronous, no await needed). + #[classmethod] + fn create(_cls: &Bound, config: &Config) -> Self { + let config = Arc::new(config.0.clone()); + AsyncAgentContext { + ctx: Arc::new(AgentContext::new(config)), + } + } + + /// List the Workspaces the current account belongs to. Returns awaitable. + fn workspaces(&self, py: Python<'_>) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp: WorkspacesResponse = ctx.workspaces().await.map_err(ErrorNewType)?.into(); + Ok(resp) + }) + .map(|b| b.unbind()) + } + + /// List the Agents in the specified Workspace. Returns awaitable. + #[pyo3(signature = (workspace_id, page = None, limit = None, name = None))] + fn agents( + &self, + py: Python<'_>, + workspace_id: String, + page: Option, + limit: Option, + name: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + let mut opts = GetAgentsOptions::new(); + if let Some(page) = page { + opts = opts.page(page); + } + if let Some(limit) = limit { + opts = opts.limit(limit); + } + if let Some(name) = name { + opts = opts.name(name); + } + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp: AgentsResponse = ctx + .agents(workspace_id, Some(opts)) + .await + .map_err(ErrorNewType)? + .into(); + Ok(resp) + }) + .map(|b| b.unbind()) + } + + /// Start a conversation with the specified Agent, blocking until the run + /// succeeds, is interrupted, or fails. Returns awaitable. + #[pyo3(signature = (agent_id, query, chat_uid = None))] + fn conversation( + &self, + py: Python<'_>, + agent_id: String, + query: String, + chat_uid: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp: ConversationResponse = ctx + .conversation(agent_id, query, chat_uid) + .await + .map_err(ErrorNewType)? + .into(); + Ok(resp) + }) + .map(|b| b.unbind()) + } + + /// Resume an interrupted conversation, blocking until the run succeeds, is + /// interrupted again, or fails. Returns awaitable. + /// + /// `answers_by_tool_call` maps `tool_call_id` (from + /// `ConversationResponse.interrupt`) to a map of question text to answer. + fn continue_conversation( + &self, + py: Python<'_>, + agent_id: String, + chat_uid: String, + message_id: String, + answers_by_tool_call: HashMap>, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp: ConversationResponse = ctx + .continue_conversation(agent_id, chat_uid, message_id, answers_by_tool_call) + .await + .map_err(ErrorNewType)? + .into(); + Ok(resp) + }) + .map(|b| b.unbind()) + } + + /// Start a conversation with the specified Agent. Returns an awaitable + /// that resolves to an `AsyncConversationStreamIter`; use `async for` on + /// it to consume run-progress events. + #[pyo3(signature = (agent_id, query, chat_uid = None))] + fn conversation_streamed( + &self, + py: Python<'_>, + agent_id: String, + query: String, + chat_uid: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let stream = ctx + .conversation_streamed(agent_id, query, chat_uid) + .await + .map_err(ErrorNewType)?; + let boxed: BoxedEventStream = Box::pin(stream); + Ok(AsyncConversationStreamIter { + stream: Arc::new(Mutex::new(boxed)), + }) + }) + .map(|b| b.unbind()) + } + + /// Resume an interrupted conversation. Returns an awaitable that resolves + /// to an `AsyncConversationStreamIter`; use `async for` on it to consume + /// run-progress events. + fn continue_conversation_streamed( + &self, + py: Python<'_>, + agent_id: String, + chat_uid: String, + message_id: String, + answers_by_tool_call: HashMap>, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let stream = ctx + .continue_conversation_streamed( + agent_id, + chat_uid, + message_id, + answers_by_tool_call, + ) + .await + .map_err(ErrorNewType)?; + let boxed: BoxedEventStream = Box::pin(stream); + Ok(AsyncConversationStreamIter { + stream: Arc::new(Mutex::new(boxed)), + }) + }) + .map(|b| b.unbind()) + } +} + +/// Async iterator of conversation-stream events, returned (once awaited) by +/// `AsyncAgentContext.conversation_streamed`/`continue_conversation_streamed`. +/// Use with `async for`. +#[pyclass] +pub(crate) struct AsyncConversationStreamIter { + stream: Arc>, +} + +#[pymethods] +impl AsyncConversationStreamIter { + fn __aiter__(slf: Py) -> Py { + slf + } + + /// Pull the next event. + /// + /// Per Python's async-iterator protocol, ending iteration means *raising* + /// `StopAsyncIteration` from the coroutine `__anext__` returns (not just + /// returning `None` — unlike the sync `__next__`/`StopIteration` case, + /// there is no automatic `Option` -> exception conversion here because + /// this method's Rust return type is the awaitable object itself + /// (`Py`, built via `future_into_py`), not the eventual value the + /// awaitable produces). + fn __anext__(&self, py: Python<'_>) -> PyResult> { + let stream = self.stream.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = stream.lock().await; + let event = match guard.next().await { + Some(item) => item.map_err(ErrorNewType)?, + None => return Err(PyStopAsyncIteration::new_err(())), + }; + Ok(ConversationStreamEvent::from(event)) + }) + .map(|b| b.unbind()) + } +} diff --git a/python/src/agent/mod.rs b/python/src/agent/mod.rs new file mode 100644 index 0000000000..7bf5e9f8ce --- /dev/null +++ b/python/src/agent/mod.rs @@ -0,0 +1,48 @@ +mod context; +mod context_async; +mod types; + +use pyo3::prelude::*; + +pub(crate) fn register_types(parent: &Bound) -> PyResult<()> { + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + Ok(()) +} diff --git a/python/src/agent/types.rs b/python/src/agent/types.rs new file mode 100644 index 0000000000..b727686d8e --- /dev/null +++ b/python/src/agent/types.rs @@ -0,0 +1,985 @@ +//! AI Agent conversation types. +//! +//! These are plain output types (never constructed from Python), so — like +//! [`crate::sharelist::types`], the closest existing analog for a pure-HTTP +//! domain — they use `#[pyclass(get_all, skip_from_py_object)]` plus a manual +//! `From` conversion rather than the `#[derive(PyObject)]` macro used by +//! `trade::types`. The macro's field-shape support +//! (`#[py(array)]`/`#[py(opt)]`) doesn't cleanly cover `Option>` (e.g. +//! `ConversationResponse::references`), and every field here is infallible to +//! convert (no `Decimal`/time parsing), so a plain `From` is simpler than +//! fighting the macro or introducing a `TryFrom` that can never actually fail. +use longbridge_python_macros::PyEnum; +use pyo3::pyclass; + +use crate::fundamental::types::JsonValue; + +/// A Workspace the current account belongs to +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct Workspace { + pub id: String, + pub name: String, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for Workspace { + fn from(v: longbridge::agent::Workspace) -> Self { + Self { + id: v.id, + name: v.name, + created_at: v.created_at, + updated_at: v.updated_at, + } + } +} + +/// Response for `AgentContext.workspaces`/`AsyncAgentContext.workspaces` +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct WorkspacesResponse { + pub workspaces: Vec, +} + +impl From for WorkspacesResponse { + fn from(v: longbridge::agent::WorkspacesResponse) -> Self { + Self { + workspaces: v.workspaces.into_iter().map(Into::into).collect(), + } + } +} + +/// An Agent in a Workspace +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct Agent { + pub uid: String, + pub name: String, + pub description: String, + pub mode: String, + pub icon: String, + pub is_published: bool, + pub published_at: i64, + pub created_at: i64, + pub updated_at: i64, +} + +impl From for Agent { + fn from(v: longbridge::agent::Agent) -> Self { + Self { + uid: v.uid, + name: v.name, + description: v.description, + mode: v.mode, + icon: v.icon, + is_published: v.is_published, + published_at: v.published_at, + created_at: v.created_at, + updated_at: v.updated_at, + } + } +} + +/// Response for `AgentContext.agents`/`AsyncAgentContext.agents` +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct AgentsResponse { + pub agents: Vec, + pub total: i32, +} + +impl From for AgentsResponse { + fn from(v: longbridge::agent::AgentsResponse) -> Self { + Self { + agents: v.agents.into_iter().map(Into::into).collect(), + total: v.total, + } + } +} + +/// Final run status of a conversation +#[pyclass(eq, eq_int, skip_from_py_object)] +#[derive(Debug, PyEnum, Copy, Clone, Hash, Eq, PartialEq)] +#[py(remote = "longbridge::agent::ConversationStatus")] +pub(crate) enum ConversationStatus { + /// The run completed successfully + Succeeded, + /// The run is paused, waiting for `AgentContext.continue_conversation` + Interrupted, + /// The run failed + Failed, + /// The run was stopped + Stopped, +} + +/// A source referenced by the answer +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct Reference { + pub index: i32, + pub title: String, + pub url: String, +} + +impl From for Reference { + fn from(v: longbridge::agent::Reference) -> Self { + Self { + index: v.index, + title: v.title, + url: v.url, + } + } +} + +/// One option of a [`Question`] +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct QuestionOption { + pub description: String, +} + +impl From for QuestionOption { + fn from(v: longbridge::agent::QuestionOption) -> Self { + Self { + description: v.description, + } + } +} + +/// One question the Agent needs you to answer +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct Question { + pub question: String, + pub options: Vec, + pub multi_select: bool, +} + +impl From for Question { + fn from(v: longbridge::agent::Question) -> Self { + Self { + question: v.question, + options: v.options.into_iter().map(Into::into).collect(), + multi_select: v.multi_select, + } + } +} + +/// Present when a conversation run is interrupted, waiting for +/// `AgentContext.continue_conversation` +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct Interrupt { + pub node_id: String, + pub tool_call_id: String, + pub questions: Vec, + pub message_id: i64, + pub chat_id: i64, +} + +impl From for Interrupt { + fn from(v: longbridge::agent::Interrupt) -> Self { + Self { + node_id: v.node_id, + tool_call_id: v.tool_call_id, + questions: v.questions.into_iter().map(Into::into).collect(), + message_id: v.message_id, + chat_id: v.chat_id, + } + } +} + +/// Present when a conversation run failed +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct AgentError { + pub code: i32, + pub message: String, +} + +impl From for AgentError { + fn from(v: longbridge::agent::AgentError) -> Self { + Self { + code: v.code, + message: v.message, + } + } +} + +/// Response for +/// `AgentContext.conversation`/`AgentContext.continue_conversation`, +/// and the final result of the streamed counterparts +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct ConversationResponse { + pub chat_uid: String, + pub message_id: String, + pub status: ConversationStatus, + pub answer: String, + pub references: Option>, + pub elapsed_time: f64, + pub interrupt: Option, + pub error: Option, +} + +impl From for ConversationResponse { + fn from(v: longbridge::agent::ConversationResponse) -> Self { + Self { + chat_uid: v.chat_uid, + message_id: v.message_id, + status: v.status.into(), + answer: v.answer, + references: v + .references + .map(|refs| refs.into_iter().map(Into::into).collect()), + elapsed_time: v.elapsed_time, + interrupt: v.interrupt.map(Into::into), + error: v.error.map(Into::into), + } + } +} + +/// Payload of a `chat_started` stream event +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone)] +pub(crate) struct ChatStartedPayload { + pub chat_uid: String, + pub message_id: String, +} + +impl From for ChatStartedPayload { + fn from(v: longbridge::agent::ChatStartedPayload) -> Self { + Self { + chat_uid: v.chat_uid, + message_id: v.message_id, + } + } +} + +/// Payload of a `message` stream event — an incremental text chunk. This is +/// the highest-frequency event; concatenate `text` fragments in arrival +/// order. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct MessagePayload { + pub text: String, + pub message_type: String, + pub key: String, + pub started_at: i64, + pub stage: String, + pub stage_title: String, + pub stage_finished_title: String, + pub outputs: Option, +} + +impl From for MessagePayload { + fn from(v: longbridge::agent::MessagePayload) -> Self { + Self { + text: v.text, + message_type: v.message_type, + key: v.key, + started_at: v.started_at, + stage: v.stage, + stage_title: v.stage_title, + stage_finished_title: v.stage_finished_title, + outputs: v.outputs.map(JsonValue), + } + } +} + +/// `inputs` of a `workflow_started` stream event +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct WorkflowStartedInputs { + pub chat_id: i64, + pub chat_uid: String, + pub message_id: String, + pub query: String, +} + +impl From for WorkflowStartedInputs { + fn from(v: longbridge::agent::WorkflowStartedInputs) -> Self { + Self { + chat_id: v.chat_id, + chat_uid: v.chat_uid, + message_id: v.message_id, + query: v.query, + } + } +} + +/// Payload of a `workflow_started` stream event, observed right after +/// `chat_started` +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct WorkflowStartedPayload { + pub hit_cache: bool, + pub inputs: WorkflowStartedInputs, + pub started_at: i64, + pub workflow_id: i64, +} + +impl From for WorkflowStartedPayload { + fn from(v: longbridge::agent::WorkflowStartedPayload) -> Self { + Self { + hit_cache: v.hit_cache, + inputs: v.inputs.into(), + started_at: v.started_at, + workflow_id: v.workflow_id, + } + } +} + +/// Payload of a `chat_finished` stream event, observed once all `message` +/// events for this round have been sent, shortly before `workflow_finished` +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct ChatFinishedPayload { + pub chat_id: i64, + pub chat_uid: String, + pub message_id: String, + pub error: String, + pub error_message: String, +} + +impl From for ChatFinishedPayload { + fn from(v: longbridge::agent::ChatFinishedPayload) -> Self { + Self { + chat_id: v.chat_id, + chat_uid: v.chat_uid, + message_id: v.message_id, + error: v.error, + error_message: v.error_message, + } + } +} + +/// Payload of a `chat_title_updated` stream event — the server auto-generates +/// a short title for the conversation as a UI convenience. Can arrive before +/// *or* after `workflow_finished`; not tied to the run's outcome. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct ChatTitleUpdatedPayload { + pub chat_id: i64, + pub chat_uid: String, + pub source: String, + pub title: String, + pub updated_at: i64, +} + +impl From for ChatTitleUpdatedPayload { + fn from(v: longbridge::agent::ChatTitleUpdatedPayload) -> Self { + Self { + chat_id: v.chat_id, + chat_uid: v.chat_uid, + source: v.source, + title: v.title, + updated_at: v.updated_at, + } + } +} + +/// Payload of a `thinking_started` stream event — the Agent has entered the +/// reasoning phase (analyzing the question, planning tool calls). Between +/// this and `ConversationStreamEvent`'s `thinking_finished`, `message` events +/// with `message_type == "think"` and tool-call events may arrive. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct ThinkingStartedPayload { + pub started_at: i64, +} + +impl From for ThinkingStartedPayload { + fn from(v: longbridge::agent::ThinkingStartedPayload) -> Self { + Self { + started_at: v.started_at, + } + } +} + +/// Payload of a `thinking_finished` stream event — the reasoning phase is +/// over; answer text (`message` with `message_type == "answer"`) follows. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct ThinkingFinishedPayload { + pub finished_at: i64, + pub elapsed_time: i32, +} + +impl From for ThinkingFinishedPayload { + fn from(v: longbridge::agent::ThinkingFinishedPayload) -> Self { + Self { + finished_at: v.finished_at, + elapsed_time: v.elapsed_time, + } + } +} + +/// Payload of a `node_tool_use_started` stream event — an ordinary tool call +/// has started. Match it to its `node_tool_use_finished` counterpart by +/// `tool_use_id`. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct NodeToolUseStartedPayload { + pub tool_use_id: String, + pub tool_name: String, + pub tool_func_name: String, + pub tool_args: String, + pub tips: String, + pub tip_chips: Vec, + pub iteration: i32, + pub started_at: i64, +} + +impl From for NodeToolUseStartedPayload { + fn from(v: longbridge::agent::NodeToolUseStartedPayload) -> Self { + Self { + tool_use_id: v.tool_use_id, + tool_name: v.tool_name, + tool_func_name: v.tool_func_name, + tool_args: v.tool_args, + tips: v.tips, + tip_chips: v.tip_chips, + iteration: v.iteration, + started_at: v.started_at, + } + } +} + +/// `outputs` of a `NodeToolUseFinishedPayload` — only carries fields meant +/// for display +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct NodeToolUseOutputs { + pub references: Option>, + pub reference_domains: Option>, + pub query: Option, + pub text: Option, + pub tool_args: Option, + pub data: Option, +} + +impl From for NodeToolUseOutputs { + fn from(v: longbridge::agent::NodeToolUseOutputs) -> Self { + Self { + references: v + .references + .map(|refs| refs.into_iter().map(Into::into).collect()), + reference_domains: v.reference_domains, + query: v.query, + text: v.text, + tool_args: v.tool_args.map(JsonValue), + data: v.data.map(JsonValue), + } + } +} + +/// Payload of a `node_tool_use_finished` stream event — the tool call has +/// ended. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct NodeToolUseFinishedPayload { + pub tool_use_id: String, + pub status: String, + pub error: String, + pub elapsed_time: f64, + pub started_at: i64, + pub tool_name: String, + pub tool_func_name: String, + pub tool_args: String, + pub tool_type: String, + pub tips: String, + pub tip_chips: Vec, + pub iteration: i32, + pub is_thinking: bool, + pub outputs: NodeToolUseOutputs, +} + +impl From for NodeToolUseFinishedPayload { + fn from(v: longbridge::agent::NodeToolUseFinishedPayload) -> Self { + Self { + tool_use_id: v.tool_use_id, + status: v.status, + error: v.error, + elapsed_time: v.elapsed_time, + started_at: v.started_at, + tool_name: v.tool_name, + tool_func_name: v.tool_func_name, + tool_args: v.tool_args, + tool_type: v.tool_type, + tips: v.tips, + tip_chips: v.tip_chips, + iteration: v.iteration, + is_thinking: v.is_thinking, + outputs: v.outputs.into(), + } + } +} + +/// Payload of a `subagent_started` stream event. When the Agent spawns a +/// subagent to work on a sub-task, the subagent's lifecycle is reported with +/// this dedicated event family instead of `node_tool_use_*`. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct SubagentStartedPayload { + pub node_id: String, + pub tool_use_id: String, + pub started_at: i64, + pub goal: String, + pub prompt: String, + pub subagent_id: String, + pub tools: Vec, +} + +impl From for SubagentStartedPayload { + fn from(v: longbridge::agent::SubagentStartedPayload) -> Self { + Self { + node_id: v.node_id, + tool_use_id: v.tool_use_id, + started_at: v.started_at, + goal: v.goal, + prompt: v.prompt, + subagent_id: v.subagent_id, + tools: v.tools.into_iter().map(JsonValue).collect(), + } + } +} + +/// Payload of a `subagent_progress` stream event, emitted every time the +/// subagent calls one of its own tools. Use it to render a live timeline +/// inside the subagent card. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct SubagentProgressPayload { + pub node_id: String, + pub parent_tool_call_id: String, + pub subagent_tool_name: String, + pub subagent_tool_args: String, + pub subagent_status: String, + pub subagent_duration_ms: i64, + pub subagent_iteration: i32, + pub started_at: i64, +} + +impl From for SubagentProgressPayload { + fn from(v: longbridge::agent::SubagentProgressPayload) -> Self { + Self { + node_id: v.node_id, + parent_tool_call_id: v.parent_tool_call_id, + subagent_tool_name: v.subagent_tool_name, + subagent_tool_args: v.subagent_tool_args, + subagent_status: v.subagent_status, + subagent_duration_ms: v.subagent_duration_ms, + subagent_iteration: v.subagent_iteration, + started_at: v.started_at, + } + } +} + +/// `outputs` of a `SubagentFinishedPayload` +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct SubagentOutputs { + pub goal: Option, + pub result: Option, + pub subagent_tools: Option>, +} + +impl From for SubagentOutputs { + fn from(v: longbridge::agent::SubagentOutputs) -> Self { + Self { + goal: v.goal, + result: v.result, + subagent_tools: v + .subagent_tools + .map(|tools| tools.into_iter().map(JsonValue).collect()), + } + } +} + +/// Payload of a `subagent_finished` stream event +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct SubagentFinishedPayload { + pub node_id: String, + pub tool_use_id: String, + pub status: String, + pub started_at: i64, + pub elapsed_time: f64, + pub error: String, + pub outputs: SubagentOutputs, +} + +impl From for SubagentFinishedPayload { + fn from(v: longbridge::agent::SubagentFinishedPayload) -> Self { + Self { + node_id: v.node_id, + tool_use_id: v.tool_use_id, + status: v.status, + started_at: v.started_at, + elapsed_time: v.elapsed_time, + error: v.error, + outputs: v.outputs.into(), + } + } +} + +/// Payload of an `agent_tool_started` stream event. When the Agent delegates +/// to another Agent as a tool, that inner run is reported with the +/// `agent_tool_*` family — the shape mirrors the subagent events. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentToolStartedPayload { + pub node_id: String, + pub tool_use_id: String, + pub agent_tool_name: String, + pub title: String, + pub started_at: i64, + pub tool_args: String, + pub tool_name: String, + pub tips: String, + pub tip_chips: Vec, + pub is_thinking: bool, +} + +impl From for AgentToolStartedPayload { + fn from(v: longbridge::agent::AgentToolStartedPayload) -> Self { + Self { + node_id: v.node_id, + tool_use_id: v.tool_use_id, + agent_tool_name: v.agent_tool_name, + title: v.title, + started_at: v.started_at, + tool_args: v.tool_args, + tool_name: v.tool_name, + tips: v.tips, + tip_chips: v.tip_chips, + is_thinking: v.is_thinking, + } + } +} + +/// Payload of an `agent_tool_progress` stream event, emitted for each inner +/// tool call the delegated Agent makes. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentToolProgressPayload { + pub node_id: String, + pub parent_tool_call_id: String, + pub agent_tool_name: String, + pub inner_tool_name: String, + pub inner_tool_args: String, + pub status: String, + pub duration_ms: i64, + pub started_at: i64, + pub is_thinking: bool, +} + +impl From for AgentToolProgressPayload { + fn from(v: longbridge::agent::AgentToolProgressPayload) -> Self { + Self { + node_id: v.node_id, + parent_tool_call_id: v.parent_tool_call_id, + agent_tool_name: v.agent_tool_name, + inner_tool_name: v.inner_tool_name, + inner_tool_args: v.inner_tool_args, + status: v.status, + duration_ms: v.duration_ms, + started_at: v.started_at, + is_thinking: v.is_thinking, + } + } +} + +/// Payload of an `agent_tool_finished` stream event +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct AgentToolFinishedPayload { + pub node_id: String, + pub tool_use_id: String, + pub agent_tool_name: String, + pub status: String, + pub started_at: i64, + pub elapsed_time: f64, + pub error: String, + pub tool_args: String, + pub outputs: Option, + pub tool_type: String, + pub tips: String, + pub tip_chips: Vec, + pub is_thinking: bool, +} + +impl From for AgentToolFinishedPayload { + fn from(v: longbridge::agent::AgentToolFinishedPayload) -> Self { + Self { + node_id: v.node_id, + tool_use_id: v.tool_use_id, + agent_tool_name: v.agent_tool_name, + status: v.status, + started_at: v.started_at, + elapsed_time: v.elapsed_time, + error: v.error, + tool_args: v.tool_args, + outputs: v.outputs.map(JsonValue), + tool_type: v.tool_type, + tips: v.tips, + tip_chips: v.tip_chips, + is_thinking: v.is_thinking, + } + } +} + +/// Payload of a `query_masked` stream event — sensitive content in the user +/// query was masked before processing. Display `masked_query` instead of the +/// original query. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct QueryMaskedPayload { + pub raw_query: String, + pub masked_query: String, +} + +impl From for QueryMaskedPayload { + fn from(v: longbridge::agent::QueryMaskedPayload) -> Self { + Self { + raw_query: v.raw_query, + masked_query: v.masked_query, + } + } +} + +/// Payload of a `plan_changed` stream event — the Agent created or updated +/// its task plan. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct PlanChangedPayload { + pub node_id: String, + pub started_at: i64, + pub outputs: Option, + pub tool_name: String, +} + +impl From for PlanChangedPayload { + fn from(v: longbridge::agent::PlanChangedPayload) -> Self { + Self { + node_id: v.node_id, + started_at: v.started_at, + outputs: v.outputs.map(JsonValue), + tool_name: v.tool_name, + } + } +} + +/// Payload of a `context_compress_started` stream event, marking the start of +/// a context-compression pass triggered by a long conversation. Unlike other +/// events, the timestamp here is an RFC 3339 string. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct ContextCompressStartedPayload { + pub started_at: String, + pub inputs: Option, +} + +impl From for ContextCompressStartedPayload { + fn from(v: longbridge::agent::ContextCompressStartedPayload) -> Self { + Self { + started_at: v.started_at, + inputs: v.inputs.map(JsonValue), + } + } +} + +/// Payload of a `context_compress_finished` stream event. Unlike other +/// events, the timestamp here is an RFC 3339 string. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct ContextCompressFinishedPayload { + pub created_at: String, + pub inputs: Option, + pub outputs: Option, +} + +impl From for ContextCompressFinishedPayload { + fn from(v: longbridge::agent::ContextCompressFinishedPayload) -> Self { + Self { + created_at: v.created_at, + inputs: v.inputs.map(JsonValue), + outputs: v.outputs.map(JsonValue), + } + } +} + +/// One event observed while streaming +/// `AgentContext.conversation_streamed`/`continue_conversation_streamed` (or +/// the `Async` counterparts). +/// +/// There's no existing precedent in this codebase for exposing a Rust +/// enum-with-payload to Python, so this flattens +/// `longbridge::agent::ConversationStreamEvent` into a single class: `kind` is +/// the discriminant (one of `"chat_started"`, `"workflow_started"`, +/// `"message"`, `"ping"`, `"thinking_started"`, `"thinking_finished"`, +/// `"node_tool_use_started"`, `"node_tool_use_finished"`, `"subagent_started"`, +/// `"subagent_progress"`, `"subagent_finished"`, `"agent_tool_started"`, +/// `"agent_tool_progress"`, `"agent_tool_finished"`, +/// `"human_interaction_required"`, `"query_masked"`, `"plan_changed"`, +/// `"context_compress_started"`, `"context_compress_finished"`, +/// `"chat_finished"`, `"workflow_finished"`, `"chat_title_updated"`, +/// `"other"`) and exactly one of the fields below sharing that name is set, +/// matching `kind` — except `"ping"`, a heartbeat with no payload, for which +/// every payload field is `None`. When `kind` is `"other"`, `other_event` +/// additionally carries the SSE envelope's `event` field (the event type +/// name). +#[pyclass(get_all, skip_from_py_object)] +#[derive(Debug, Clone, Default)] +pub(crate) struct ConversationStreamEvent { + pub kind: String, + pub chat_started: Option, + pub workflow_started: Option, + pub message: Option, + pub thinking_started: Option, + pub thinking_finished: Option, + pub node_tool_use_started: Option, + pub node_tool_use_finished: Option, + pub subagent_started: Option, + pub subagent_progress: Option, + pub subagent_finished: Option, + pub agent_tool_started: Option, + pub agent_tool_progress: Option, + pub agent_tool_finished: Option, + pub human_interaction_required: Option, + pub query_masked: Option, + pub plan_changed: Option, + pub context_compress_started: Option, + pub context_compress_finished: Option, + pub chat_finished: Option, + pub workflow_finished: Option, + pub chat_title_updated: Option, + pub other_event: Option, + pub other: Option, +} + +impl From for ConversationStreamEvent { + fn from(v: longbridge::agent::ConversationStreamEvent) -> Self { + use longbridge::agent::ConversationStreamEvent as E; + + match v { + E::ChatStarted(payload) => Self { + kind: "chat_started".to_string(), + chat_started: Some(payload.into()), + ..Default::default() + }, + E::WorkflowStarted(payload) => Self { + kind: "workflow_started".to_string(), + workflow_started: Some(payload.into()), + ..Default::default() + }, + E::Message(payload) => Self { + kind: "message".to_string(), + message: Some(payload.into()), + ..Default::default() + }, + E::Ping => Self { + kind: "ping".to_string(), + ..Default::default() + }, + E::ThinkingStarted(payload) => Self { + kind: "thinking_started".to_string(), + thinking_started: Some(payload.into()), + ..Default::default() + }, + E::ThinkingFinished(payload) => Self { + kind: "thinking_finished".to_string(), + thinking_finished: Some(payload.into()), + ..Default::default() + }, + E::NodeToolUseStarted(payload) => Self { + kind: "node_tool_use_started".to_string(), + node_tool_use_started: Some(payload.into()), + ..Default::default() + }, + E::NodeToolUseFinished(payload) => Self { + kind: "node_tool_use_finished".to_string(), + node_tool_use_finished: Some(payload.into()), + ..Default::default() + }, + E::SubagentStarted(payload) => Self { + kind: "subagent_started".to_string(), + subagent_started: Some(payload.into()), + ..Default::default() + }, + E::SubagentProgress(payload) => Self { + kind: "subagent_progress".to_string(), + subagent_progress: Some(payload.into()), + ..Default::default() + }, + E::SubagentFinished(payload) => Self { + kind: "subagent_finished".to_string(), + subagent_finished: Some(payload.into()), + ..Default::default() + }, + E::AgentToolStarted(payload) => Self { + kind: "agent_tool_started".to_string(), + agent_tool_started: Some(payload.into()), + ..Default::default() + }, + E::AgentToolProgress(payload) => Self { + kind: "agent_tool_progress".to_string(), + agent_tool_progress: Some(payload.into()), + ..Default::default() + }, + E::AgentToolFinished(payload) => Self { + kind: "agent_tool_finished".to_string(), + agent_tool_finished: Some(payload.into()), + ..Default::default() + }, + E::HumanInteractionRequired(resp) => Self { + kind: "human_interaction_required".to_string(), + human_interaction_required: Some(resp.into()), + ..Default::default() + }, + E::QueryMasked(payload) => Self { + kind: "query_masked".to_string(), + query_masked: Some(payload.into()), + ..Default::default() + }, + E::PlanChanged(payload) => Self { + kind: "plan_changed".to_string(), + plan_changed: Some(payload.into()), + ..Default::default() + }, + E::ContextCompressStarted(payload) => Self { + kind: "context_compress_started".to_string(), + context_compress_started: Some(payload.into()), + ..Default::default() + }, + E::ContextCompressFinished(payload) => Self { + kind: "context_compress_finished".to_string(), + context_compress_finished: Some(payload.into()), + ..Default::default() + }, + E::ChatFinished(payload) => Self { + kind: "chat_finished".to_string(), + chat_finished: Some(payload.into()), + ..Default::default() + }, + E::WorkflowFinished(resp) => Self { + kind: "workflow_finished".to_string(), + workflow_finished: Some(resp.into()), + ..Default::default() + }, + E::ChatTitleUpdated(payload) => Self { + kind: "chat_title_updated".to_string(), + chat_title_updated: Some(payload.into()), + ..Default::default() + }, + E::Other { event, data } => Self { + kind: "other".to_string(), + other_event: Some(event), + other: Some(JsonValue(data)), + ..Default::default() + }, + } + } +} diff --git a/python/src/lib.rs b/python/src/lib.rs index d63e3b8e47..5899ba8f38 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -1,3 +1,11 @@ +// `longbridge::Error` is intentionally larger than clippy's default +// `result_large_err` threshold (kept rich for diagnostics); the core +// `longbridge` crate allows this crate-wide (rust/src/lib.rs) for the same +// reason, and it also surfaces here once blocking calls are wrapped in +// `py.detach(|| ...)` closures (e.g. `agent::context`). +#![allow(clippy::result_large_err)] + +mod agent; mod alert; mod asset; mod async_callback; @@ -33,6 +41,7 @@ fn longbridge(py: Python<'_>, m: Bound) -> PyResult<()> { openapi.add_class::()?; openapi.add_class::()?; openapi.add_class::()?; + agent::register_types(&openapi)?; asset::register_types(&openapi)?; alert::register_types(&openapi)?; dca::register_types(&openapi)?; diff --git a/rust/crates/httpclient/Cargo.toml b/rust/crates/httpclient/Cargo.toml index ee9b6c5690..8913bda2ae 100644 --- a/rust/crates/httpclient/Cargo.toml +++ b/rust/crates/httpclient/Cargo.toml @@ -11,7 +11,8 @@ longbridge-oauth.workspace = true futures-util.workspace = true hmac.workspace = true parking_lot.workspace = true -reqwest = { workspace = true, features = ["rustls-tls", "json"] } +reqwest = { workspace = true, features = ["rustls-tls", "json", "stream"] } +eventsource-stream.workspace = true serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["raw_value"] } sha1.workspace = true diff --git a/rust/crates/httpclient/src/error.rs b/rust/crates/httpclient/src/error.rs index ddf57047a6..3e844a7030 100644 --- a/rust/crates/httpclient/src/error.rs +++ b/rust/crates/httpclient/src/error.rs @@ -92,6 +92,10 @@ pub enum HttpClientError { /// OAuth error #[error("oauth error: {0}")] OAuth(String), + + /// Server-sent events stream error + #[error("sse stream error: {0}")] + Sse(String), } /// Represents an HTTP error diff --git a/rust/crates/httpclient/src/lib.rs b/rust/crates/httpclient/src/lib.rs index 544c3050bf..e91a294767 100644 --- a/rust/crates/httpclient/src/lib.rs +++ b/rust/crates/httpclient/src/lib.rs @@ -16,6 +16,7 @@ mod timestamp; pub use client::HttpClient; pub use config::{AuthConfig, HttpClientConfig}; pub use error::{HttpClientError, HttpClientResult, HttpError}; +pub use eventsource_stream::Event as SseEvent; pub use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn}; pub use qs::QsError; pub use request::{FromPayload, Json, RequestBuilder, ToPayload}; diff --git a/rust/crates/httpclient/src/request.rs b/rust/crates/httpclient/src/request.rs index 8477a7fc3e..cd91ec3f87 100644 --- a/rust/crates/httpclient/src/request.rs +++ b/rust/crates/httpclient/src/request.rs @@ -3,13 +3,16 @@ use std::{ error::Error, fmt::Debug, marker::PhantomData, + pin::Pin, time::{Duration, Instant}, }; +use eventsource_stream::{Event as SseEvent, Eventsource}; +use futures_util::{Stream, StreamExt}; use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn}; use reqwest::{ Method, StatusCode, - header::{HeaderMap, HeaderName, HeaderValue}, + header::{ACCEPT, HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -126,6 +129,7 @@ pub struct RequestBuilder<'a, T, Q, R> { body: Option, query_params: Option, dc_restrict: Option, + timeout: Option, mark_resp: PhantomData, } @@ -139,6 +143,7 @@ impl<'a> RequestBuilder<'a, (), (), ()> { body: None, query_params: None, dc_restrict: None, + timeout: None, mark_resp: PhantomData, } } @@ -159,6 +164,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { body: Some(body), query_params: self.query_params, dc_restrict: self.dc_restrict, + timeout: self.timeout, mark_resp: self.mark_resp, } } @@ -191,6 +197,17 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { self } + /// Override the default request timeout ([`REQUEST_TIMEOUT`], 30s) for + /// this call. Most endpoints respond quickly and should stick with the + /// default; this exists for the rare domain where a slower backend (e.g. + /// an LLM-backed one) makes the shared default too tight, without + /// changing that default for every other endpoint. + #[must_use] + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + /// Set the query string #[must_use] pub fn query_params(self, params: Q2) -> RequestBuilder<'a, T, Q2, R> @@ -205,6 +222,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { body: self.body, query_params: Some(params), dc_restrict: self.dc_restrict, + timeout: self.timeout, mark_resp: self.mark_resp, } } @@ -223,16 +241,40 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> { body: self.body, query_params: self.query_params, dc_restrict: self.dc_restrict, + timeout: self.timeout, mark_resp: PhantomData, } } } +/// Parse the `{code, message, data}` OpenAPI response envelope, given the HTTP +/// status and trace id already extracted from the response. Shared by the +/// blocking (`do_send`) and streaming (`send_events`) request paths, since both +/// can receive this envelope as an error body (streaming responses only use SSE +/// framing once the server has committed to a 200 status). +fn parse_response_envelope( + status: StatusCode, + trace_id: &str, + text: &str, +) -> HttpClientResult> { + match serde_json::from_str::(text) { + Ok(resp) if resp.code == 0 => resp.data.ok_or(HttpClientError::UnexpectedResponse), + Ok(resp) => Err(HttpClientError::OpenApi { + code: resp.code, + message: resp.message, + trace_id: trace_id.to_string(), + }), + Err(err) if status == StatusCode::OK => { + Err(HttpClientError::DeserializeResponseBody(err.to_string())) + } + Err(_) => Err(HttpClientError::BadStatus(status)), + } +} + impl RequestBuilder<'_, T, Q, R> where T: ToPayload, Q: Serialize + Send, - R: FromPayload, { async fn http_url(&self) -> &str { if let Some(url) = self.client.config.http_url.as_deref() { @@ -242,7 +284,10 @@ where if is_cn().await { HTTP_URL_CN } else { HTTP_URL } } - async fn do_send(&self) -> HttpClientResult { + /// Resolve auth/dc-region, build and sign the underlying + /// [`reqwest::Request`]. Shared by both the blocking (`do_send`) and + /// streaming (`send_events`) request paths. + async fn build_request(&self) -> HttpClientResult { let HttpClient { http_cli, config, @@ -362,10 +407,25 @@ where tracing::info!(method = %request.method(), url = %request.url(), "http request"); } + Ok(request) + } +} + +impl RequestBuilder<'_, T, Q, R> +where + T: ToPayload, + Q: Serialize + Send, + R: FromPayload, +{ + async fn do_send(&self) -> HttpClientResult { + let http_cli = &self.client.http_cli; + let request = self.build_request().await?; + let s = Instant::now(); + let timeout = self.timeout.unwrap_or(REQUEST_TIMEOUT); // send request - let (status, trace_id, text) = tokio::time::timeout(REQUEST_TIMEOUT, async move { + let (status, trace_id, text) = tokio::time::timeout(timeout, async move { let resp = http_cli .execute(request) .await @@ -388,20 +448,9 @@ where tracing::info!(duration = ?s.elapsed(), body = %text.as_str(), "http response"); - let resp = match serde_json::from_str::(&text) { - Ok(resp) if resp.code == 0 => resp.data.ok_or(HttpClientError::UnexpectedResponse), - Ok(resp) => Err(HttpClientError::OpenApi { - code: resp.code, - message: resp.message, - trace_id, - }), - Err(err) if status == StatusCode::OK => { - Err(HttpClientError::DeserializeResponseBody(err.to_string())) - } - Err(_) => Err(HttpClientError::BadStatus(status)), - }?; + let data = parse_response_envelope(status, &trace_id, &text)?; - R::parse_from_bytes(resp.get().as_bytes()) + R::parse_from_bytes(data.get().as_bytes()) .map_err(|err| HttpClientError::DeserializeResponseBody(err.to_string())) } @@ -432,3 +481,63 @@ where } } } + +impl RequestBuilder<'_, T, Q, ()> +where + T: ToPayload, + Q: Serialize + Send, +{ + /// Send the request with `Accept: text/event-stream` and return a stream of + /// parsed SSE events, instead of buffering the full response body like + /// [`send`](RequestBuilder::send) does. There's no automatic 429 retry here + /// — once a stream starts delivering events it can't be replayed as a + /// whole; a failure is handed back to the caller to decide whether to + /// start a new call. + pub async fn send_events( + self, + ) -> HttpClientResult> + Send>>> { + let http_cli = self.client.http_cli.clone(); + let timeout = self.timeout.unwrap_or(REQUEST_TIMEOUT); + let mut request = self.build_request().await?; + request + .headers_mut() + .insert(ACCEPT, HeaderValue::from_static("text/event-stream")); + + // Only bounds establishing the connection (getting a status/headers + // back), not the subsequent event-by-event reads below — those can + // legitimately take as long as the agent takes to answer. + let resp = tokio::time::timeout(timeout, http_cli.execute(request)) + .await + .map_err(|_| HttpClientError::RequestTimeout)? + .map_err(|err| HttpClientError::Http(err.into()))?; + let status = resp.status(); + + if status != StatusCode::OK { + // Error responses are still a one-shot JSON body ({code, message}), not SSE. + let trace_id = resp + .headers() + .get("x-trace-id") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let text = resp + .text() + .await + .map_err(|err| HttpClientError::Http(err.into()))?; + return Err(match parse_response_envelope(status, &trace_id, &text) { + Ok(_) => HttpClientError::UnexpectedResponse, + Err(err) => err, + }); + } + + let stream = resp.bytes_stream().eventsource().map(|item| { + item.map_err(|err| match err { + eventsource_stream::EventStreamError::Transport(err) => { + HttpClientError::Http(err.into()) + } + err => HttpClientError::Sse(err.to_string()), + }) + }); + Ok(Box::pin(stream)) + } +} diff --git a/rust/src/agent/context.rs b/rust/src/agent/context.rs new file mode 100644 index 0000000000..ff98c4b276 --- /dev/null +++ b/rust/src/agent/context.rs @@ -0,0 +1,525 @@ +use std::{sync::Arc, time::Duration}; + +use futures_util::{Stream, StreamExt}; +use longbridge_httpcli::{HttpClient, Json, Method}; +use serde::{Deserialize, Serialize}; +use tracing::{Subscriber, dispatcher, instrument::WithSubscriber}; + +use crate::{Config, Result, agent::types::*}; + +/// The shared httpclient default (30s) is tuned for fast REST calls and is +/// too tight here: in blocking mode the server holds the connection silent +/// until the whole LLM turn is done, and that can legitimately take longer. +/// Only agent calls get this longer budget — every other domain keeps the +/// 30s default. +const AGENT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); + +struct InnerAgentContext { + http_cli: HttpClient, + log_subscriber: Arc, +} + +impl Drop for InnerAgentContext { + fn drop(&mut self) { + dispatcher::with_default(&self.log_subscriber.clone().into(), || { + tracing::info!("agent context dropped"); + }); + } +} + +/// AI Agent conversation context. +/// +/// Reference: +#[derive(Clone)] +pub struct AgentContext(Arc); + +#[derive(Debug, Deserialize)] +struct SseEnvelope { + event: String, + #[serde(default)] + data: serde_json::Value, + /// Only present on `plan_changed`, as a sibling of `data` rather than a + /// field inside it — see [`PlanChangedPayload::tool_name`]. + #[serde(default)] + tool_name: Option, +} + +/// Parse one raw SSE frame into a [`ConversationStreamEvent`], threading the +/// `chat_uid`/`message_id` captured from an earlier `chat_started` event (the +/// `workflow_finished` event doesn't repeat them) through `started`. +fn map_conversation_event( + item: longbridge_httpcli::HttpClientResult, + started: &mut Option<(String, String)>, +) -> Result { + let event = item?; + let envelope: SseEnvelope = serde_json::from_str(&event.data)?; + Ok(match envelope.event.as_str() { + "chat_started" => { + let payload: ChatStartedPayload = serde_json::from_value(envelope.data)?; + *started = Some((payload.chat_uid.clone(), payload.message_id.clone())); + ConversationStreamEvent::ChatStarted(payload) + } + "message" => ConversationStreamEvent::Message(serde_json::from_value(envelope.data)?), + "workflow_started" => { + ConversationStreamEvent::WorkflowStarted(serde_json::from_value(envelope.data)?) + } + "ping" => ConversationStreamEvent::Ping, + "thinking_started" => { + ConversationStreamEvent::ThinkingStarted(serde_json::from_value(envelope.data)?) + } + "thinking_finished" => { + ConversationStreamEvent::ThinkingFinished(serde_json::from_value(envelope.data)?) + } + "node_tool_use_started" => { + ConversationStreamEvent::NodeToolUseStarted(serde_json::from_value(envelope.data)?) + } + "node_tool_use_finished" => { + ConversationStreamEvent::NodeToolUseFinished(serde_json::from_value(envelope.data)?) + } + "subagent_started" => { + ConversationStreamEvent::SubagentStarted(serde_json::from_value(envelope.data)?) + } + "subagent_progress" => { + ConversationStreamEvent::SubagentProgress(serde_json::from_value(envelope.data)?) + } + "subagent_finished" => { + ConversationStreamEvent::SubagentFinished(serde_json::from_value(envelope.data)?) + } + "agent_tool_started" => { + ConversationStreamEvent::AgentToolStarted(serde_json::from_value(envelope.data)?) + } + "agent_tool_progress" => { + ConversationStreamEvent::AgentToolProgress(serde_json::from_value(envelope.data)?) + } + "agent_tool_finished" => { + ConversationStreamEvent::AgentToolFinished(serde_json::from_value(envelope.data)?) + } + "human_interaction_required" => { + let interrupt: Interrupt = serde_json::from_value(envelope.data)?; + ConversationStreamEvent::HumanInteractionRequired( + ConversationResponse::from_stream_interrupt(started.clone(), interrupt), + ) + } + "query_masked" => { + ConversationStreamEvent::QueryMasked(serde_json::from_value(envelope.data)?) + } + "plan_changed" => { + let mut payload: PlanChangedPayload = serde_json::from_value(envelope.data)?; + payload.tool_name = envelope.tool_name.clone().unwrap_or_default(); + ConversationStreamEvent::PlanChanged(payload) + } + "context_compress_started" => { + ConversationStreamEvent::ContextCompressStarted(serde_json::from_value(envelope.data)?) + } + "context_compress_finished" => { + ConversationStreamEvent::ContextCompressFinished(serde_json::from_value(envelope.data)?) + } + "chat_finished" => { + ConversationStreamEvent::ChatFinished(serde_json::from_value(envelope.data)?) + } + "chat_title_updated" => { + ConversationStreamEvent::ChatTitleUpdated(serde_json::from_value(envelope.data)?) + } + "workflow_finished" => { + let payload: WorkflowFinishedPayload = serde_json::from_value(envelope.data)?; + ConversationStreamEvent::WorkflowFinished(ConversationResponse::from_stream_parts( + started.clone(), + payload, + )) + } + _ => ConversationStreamEvent::Other { + event: envelope.event, + data: envelope.data, + }, + }) +} + +impl AgentContext { + /// Create an [`AgentContext`] + pub fn new(config: Arc) -> Self { + let log_subscriber = config.create_log_subscriber("agent"); + dispatcher::with_default(&log_subscriber.clone().into(), || { + tracing::info!(language = ?config.language, "creating agent context"); + }); + let ctx = Self(Arc::new(InnerAgentContext { + http_cli: config.create_http_client(), + log_subscriber, + })); + dispatcher::with_default(&ctx.0.log_subscriber.clone().into(), || { + tracing::info!("agent context created"); + }); + ctx + } + + /// Returns the log subscriber + #[inline] + pub fn log_subscriber(&self) -> Arc { + self.0.log_subscriber.clone() + } + + /// List the Workspaces the current account belongs to. + /// + /// Path: `GET /v1/ai/workspaces` + pub async fn workspaces(&self) -> Result { + Ok(self + .0 + .http_cli + .request(Method::GET, "/v1/ai/workspaces") + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + + /// List the Agents in the specified Workspace. + /// + /// Path: `GET /v1/ai/workspaces/{id}/agents` + pub async fn agents( + &self, + workspace_id: impl Into, + opts: impl Into>, + ) -> Result { + let workspace_id = workspace_id.into(); + Ok(self + .0 + .http_cli + .request( + Method::GET, + format!("/v1/ai/workspaces/{workspace_id}/agents"), + ) + .query_params(opts.into().unwrap_or_default()) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + + /// Start a conversation with the specified Agent, blocking until the run + /// succeeds, is interrupted, or fails. + /// + /// Path: `POST /v1/ai/agents/{id}/conversations` + pub async fn conversation( + &self, + agent_id: impl Into, + query: impl Into, + chat_uid: impl Into>, + ) -> Result { + #[derive(Debug, Serialize)] + struct Body { + query: String, + #[serde(skip_serializing_if = "Option::is_none")] + chat_uid: Option, + } + + let agent_id = agent_id.into(); + Ok(self + .0 + .http_cli + .request( + Method::POST, + format!("/v1/ai/agents/{agent_id}/conversations"), + ) + .header("Accept", "application/json") + .body(Json(Body { + query: query.into(), + chat_uid: chat_uid.into(), + })) + .timeout(AGENT_REQUEST_TIMEOUT) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + + /// Resume an interrupted conversation, blocking until the run succeeds, is + /// interrupted again, or fails. + /// + /// Path: `POST + /// /v1/ai/agents/{id}/conversations/{chat_uid}/messages/{message_id}/ + /// continue` + pub async fn continue_conversation( + &self, + agent_id: impl Into, + chat_uid: impl Into, + message_id: impl Into, + answers: AnswersByToolCall, + ) -> Result { + #[derive(Debug, Serialize)] + struct Body { + answers_by_tool_call: AnswersByToolCall, + } + + let agent_id = agent_id.into(); + let chat_uid = chat_uid.into(); + let message_id = message_id.into(); + Ok(self + .0 + .http_cli + .request( + Method::POST, + format!( + "/v1/ai/agents/{agent_id}/conversations/{chat_uid}/messages/{message_id}/continue" + ), + ) + .header("Accept", "application/json") + .body(Json(Body { + answers_by_tool_call: answers, + })) + .timeout(AGENT_REQUEST_TIMEOUT) + .response::>() + .send() + .with_subscriber(self.0.log_subscriber.clone()) + .await? + .0) + } + + /// Start a conversation with the specified Agent, returning a [`Stream`] of + /// run-progress events over SSE. The run's outcome is carried by a + /// [`ConversationStreamEvent::WorkflowFinished`] event (succeeded, failed, + /// or stopped) or, if the Agent needs more input from you, a + /// [`ConversationStreamEvent::HumanInteractionRequired`] event instead — + /// an interrupted run never emits `WorkflowFinished`. Neither is + /// necessarily the last item — the server may still emit a few more + /// housekeeping events (e.g. + /// [`ConversationStreamEvent::ChatTitleUpdated`]) before actually closing + /// the connection, so keep draining the stream until it ends rather than + /// stopping as soon as you see one. + /// + /// Path: `POST /v1/ai/agents/{id}/conversations` (`Accept: + /// text/event-stream`) + pub async fn conversation_streamed( + &self, + agent_id: impl Into, + query: impl Into, + chat_uid: impl Into>, + ) -> Result> + Send + 'static> { + #[derive(Debug, Serialize)] + struct Body { + query: String, + #[serde(skip_serializing_if = "Option::is_none")] + chat_uid: Option, + } + + let agent_id = agent_id.into(); + let raw = self + .0 + .http_cli + .request( + Method::POST, + format!("/v1/ai/agents/{agent_id}/conversations"), + ) + .body(Json(Body { + query: query.into(), + chat_uid: chat_uid.into(), + })) + .timeout(AGENT_REQUEST_TIMEOUT) + .send_events() + .with_subscriber(self.0.log_subscriber.clone()) + .await?; + + let mut started: Option<(String, String)> = None; + Ok(raw.map(move |item| map_conversation_event(item, &mut started))) + } + + /// Resume an interrupted conversation, returning a [`Stream`] of + /// run-progress events over SSE. + /// + /// Path: `POST + /// /v1/ai/agents/{id}/conversations/{chat_uid}/messages/{message_id}/ + /// continue` (`Accept: text/event-stream`) + pub async fn continue_conversation_streamed( + &self, + agent_id: impl Into, + chat_uid: impl Into, + message_id: impl Into, + answers: AnswersByToolCall, + ) -> Result> + Send + 'static> { + #[derive(Debug, Serialize)] + struct Body { + answers_by_tool_call: AnswersByToolCall, + } + + let agent_id = agent_id.into(); + let chat_uid = chat_uid.into(); + let message_id = message_id.into(); + // We already know chat_uid/message_id from the caller (unlike a brand-new + // conversation) — seed `started` so the final ConversationResponse carries + // them even if the server doesn't re-emit a `chat_started` event here. + let mut started = Some((chat_uid.clone(), message_id.clone())); + let raw = self + .0 + .http_cli + .request( + Method::POST, + format!( + "/v1/ai/agents/{agent_id}/conversations/{chat_uid}/messages/{message_id}/continue" + ), + ) + .body(Json(Body { + answers_by_tool_call: answers, + })) + .timeout(AGENT_REQUEST_TIMEOUT) + .send_events() + .with_subscriber(self.0.log_subscriber.clone()) + .await?; + + Ok(raw.map(move |item| map_conversation_event(item, &mut started))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // The `data:` payloads of the three example SSE frames from + // https://open.longbridge.com/en/docs/ai/chat/conversation + const CHAT_STARTED: &str = r#"{"event":"chat_started","workflow_run_id":"wr_1","data":{"chat_uid":"ct_9f2c1a5b","message_id":42}}"#; + const MESSAGE: &str = r#"{"event":"message","workflow_run_id":"wr_1","data":{"text":"Tesla"}}"#; + const WORKFLOW_FINISHED: &str = r#"{"event":"workflow_finished","workflow_run_id":"wr_1","data":{"status":"succeeded","elapsed_time":3.21,"outputs":{"answer":"Tesla (TSLA.US) recently..."}}}"#; + + // The four event types below aren't in the docs — captured verbatim from + // real traffic during manual live testing (see conversation history). + const WORKFLOW_STARTED: &str = r#"{"event":"workflow_started","workflow_run_id":"wr_1","data":{"hit_cache":false,"inputs":{"chat_id":834552,"chat_uid":"ct_9f2c1a5b","message_id":42,"query":"How has Tesla stock performed recently?"},"started_at":1784545150,"workflow_id":176476}}"#; + const PING: &str = r#"{"event":"ping","workflow_run_id":"wr_1","data":null}"#; + const CHAT_FINISHED: &str = r#"{"event":"chat_finished","workflow_run_id":"wr_1","data":{"chat_id":834552,"chat_uid":"ct_9f2c1a5b","error":"","error_message":"","message_id":42}}"#; + const CHAT_TITLE_UPDATED: &str = r#"{"event":"chat_title_updated","workflow_run_id":"wr_1","data":{"chat_id":834552,"chat_uid":"ct_9f2c1a5b","source":"ai_generated","title":"Tesla stock performance","updated_at":1784546957}}"#; + + fn sse(data: &str) -> longbridge_httpcli::HttpClientResult { + Ok(longbridge_httpcli::SseEvent { + event: "message".to_string(), + data: data.to_string(), + id: String::new(), + retry: None, + }) + } + + #[test] + fn map_conversation_event_full_sequence() { + let mut started = None; + + match map_conversation_event(sse(CHAT_STARTED), &mut started).unwrap() { + ConversationStreamEvent::ChatStarted(payload) => { + assert_eq!(payload.chat_uid, "ct_9f2c1a5b"); + assert_eq!(payload.message_id, "42"); + } + other => panic!("unexpected event: {other:?}"), + } + assert_eq!(started, Some(("ct_9f2c1a5b".to_string(), "42".to_string()))); + + // The real event stream is richer than the docs' three-event example + // — this exercises the fuller, real-world sequence, including + // `chat_title_updated` arriving *after* `workflow_finished` (observed + // live; see the "drain to the stream's natural end" fix). + match map_conversation_event(sse(WORKFLOW_STARTED), &mut started).unwrap() { + ConversationStreamEvent::WorkflowStarted(payload) => { + assert!(!payload.hit_cache); + assert_eq!(payload.inputs.chat_uid, "ct_9f2c1a5b"); + assert_eq!(payload.inputs.message_id, "42"); + assert_eq!(payload.workflow_id, 176476); + } + other => panic!("unexpected event: {other:?}"), + } + + match map_conversation_event(sse(MESSAGE), &mut started).unwrap() { + ConversationStreamEvent::Message(payload) => assert_eq!(payload.text, "Tesla"), + other => panic!("unexpected event: {other:?}"), + } + + match map_conversation_event(sse(PING), &mut started).unwrap() { + ConversationStreamEvent::Ping => {} + other => panic!("unexpected event: {other:?}"), + } + + match map_conversation_event(sse(CHAT_FINISHED), &mut started).unwrap() { + ConversationStreamEvent::ChatFinished(payload) => { + assert_eq!(payload.chat_uid, "ct_9f2c1a5b"); + assert_eq!(payload.message_id, "42"); + assert_eq!(payload.error, ""); + assert_eq!(payload.error_message, ""); + } + other => panic!("unexpected event: {other:?}"), + } + + match map_conversation_event(sse(WORKFLOW_FINISHED), &mut started).unwrap() { + ConversationStreamEvent::WorkflowFinished(resp) => { + assert_eq!(resp.chat_uid, "ct_9f2c1a5b"); + assert_eq!(resp.message_id, "42"); + assert_eq!(resp.status, ConversationStatus::Succeeded); + assert_eq!(resp.answer, "Tesla (TSLA.US) recently..."); + } + other => panic!("unexpected event: {other:?}"), + } + + // Arrives *after* workflow_finished in this (real, observed) ordering. + match map_conversation_event(sse(CHAT_TITLE_UPDATED), &mut started).unwrap() { + ConversationStreamEvent::ChatTitleUpdated(payload) => { + assert_eq!(payload.chat_uid, "ct_9f2c1a5b"); + assert_eq!(payload.source, "ai_generated"); + assert_eq!(payload.title, "Tesla stock performance"); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn map_conversation_event_unknown_type_falls_back_to_other() { + let mut started = None; + let json = r#"{"event":"some_future_event","data":{"foo":"bar"}}"#; + match map_conversation_event(sse(json), &mut started).unwrap() { + ConversationStreamEvent::Other { event, data } => { + assert_eq!(event, "some_future_event"); + assert_eq!(data["foo"], "bar"); + } + other => panic!("unexpected event: {other:?}"), + } + } + + // https://github.com/longbridge/developers/pull/1176 — an interrupted + // run's stream never emits `workflow_finished`; `human_interaction_required` + // is the terminal event instead. + const HUMAN_INTERACTION_REQUIRED: &str = r#"{"event":"human_interaction_required","workflow_run_id":"wr_1","data":{"node_id":"n_ask_human","tool_call_id":"call_abc123","questions":[{"question":"Which time range would you like to check?","options":[{"description":"Past week"},{"description":"Past month"}],"multi_select":false}],"message_id":43,"chat_id":1001}}"#; + + #[test] + fn map_conversation_event_interrupted_sequence_has_no_workflow_finished() { + let mut started = None; + map_conversation_event(sse(CHAT_STARTED), &mut started).unwrap(); + map_conversation_event(sse(WORKFLOW_STARTED), &mut started).unwrap(); + + match map_conversation_event(sse(HUMAN_INTERACTION_REQUIRED), &mut started).unwrap() { + ConversationStreamEvent::HumanInteractionRequired(resp) => { + assert_eq!(resp.chat_uid, "ct_9f2c1a5b"); + assert_eq!(resp.message_id, "42"); + assert_eq!(resp.status, ConversationStatus::Interrupted); + let interrupt = resp.interrupt.expect("interrupt"); + assert_eq!(interrupt.node_id, "n_ask_human"); + assert_eq!(interrupt.tool_call_id, "call_abc123"); + } + other => panic!("unexpected event: {other:?}"), + } + + // The stream still ends with `chat_finished`, just never emits + // `workflow_finished`. + match map_conversation_event(sse(CHAT_FINISHED), &mut started).unwrap() { + ConversationStreamEvent::ChatFinished(_) => {} + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn map_conversation_event_plan_changed_picks_up_sibling_tool_name() { + let mut started = None; + // `tool_name` sits outside `data`, as a sibling of `event`/`data` in + // the raw envelope. + let json = r#"{"event":"plan_changed","workflow_run_id":"wr_1","tool_name":"planner","data":{"node_id":"n_plan","started_at":1752048000}}"#; + match map_conversation_event(sse(json), &mut started).unwrap() { + ConversationStreamEvent::PlanChanged(payload) => { + assert_eq!(payload.node_id, "n_plan"); + assert_eq!(payload.tool_name, "planner"); + } + other => panic!("unexpected event: {other:?}"), + } + } +} diff --git a/rust/src/agent/mod.rs b/rust/src/agent/mod.rs new file mode 100644 index 0000000000..cd795a48ec --- /dev/null +++ b/rust/src/agent/mod.rs @@ -0,0 +1,11 @@ +//! AI Agent conversation types and context +mod context; +mod stream; +pub mod types; + +pub use context::AgentContext; +pub use stream::{ + ConversationStreamIter, ConversationStreamSubscription, conversation_stream_iter, + drive_conversation_stream, +}; +pub use types::*; diff --git a/rust/src/agent/stream.rs b/rust/src/agent/stream.rs new file mode 100644 index 0000000000..8105492889 --- /dev/null +++ b/rust/src/agent/stream.rs @@ -0,0 +1,204 @@ +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use futures_util::{Stream, StreamExt}; +use tokio::{sync::Notify, task::AbortHandle}; + +use crate::{ + Error, Result, + agent::types::{ConversationResponse, ConversationStreamEvent}, +}; + +/// Drive a conversation event stream to completion, invoking `on_event` for +/// every event, and returning the final [`ConversationResponse`] once a +/// [`ConversationStreamEvent::WorkflowFinished`] or +/// [`ConversationStreamEvent::HumanInteractionRequired`] event is observed +/// (or an error if the stream ends before either happens). An interrupted +/// run emits `HumanInteractionRequired` instead of `WorkflowFinished`, never +/// both, so exactly one of the two is expected per run. +/// +/// Used by binding layers that are call-scoped-callback shaped (C, C++, +/// Node.js) — every other binding either pulls synchronously +/// ([`conversation_stream_iter`]) or drives with real backpressure +/// ([`ConversationStreamSubscription`]). +pub async fn drive_conversation_stream( + mut stream: S, + mut on_event: F, +) -> Result +where + S: Stream> + Send + Unpin, + F: FnMut(ConversationStreamEvent) + Send, +{ + let mut final_response = None; + while let Some(event) = stream.next().await { + let event = event?; + match &event { + ConversationStreamEvent::WorkflowFinished(resp) + | ConversationStreamEvent::HumanInteractionRequired(resp) => { + final_response = Some(resp.clone()); + } + _ => {} + } + on_event(event); + } + final_response.ok_or(Error::ConversationStreamEnded) +} + +/// A blocking [`Iterator`] over conversation stream events, backed by a +/// background task on the shared runtime ([`crate::runtime_handle`]). Useful +/// for sync/FFI bindings that need to pull events one at a time from a plain OS +/// thread instead of polling a [`Stream`] directly. +pub struct ConversationStreamIter(std::sync::mpsc::Receiver>); + +impl Iterator for ConversationStreamIter { + type Item = Result; + + fn next(&mut self) -> Option { + self.0.recv().ok() + } +} + +/// Adapt a conversation event [`Stream`] into a blocking +/// [`ConversationStreamIter`]. +pub fn conversation_stream_iter( + stream: impl Stream> + Send + 'static, +) -> ConversationStreamIter { + let (tx, rx) = std::sync::mpsc::channel(); + let mut stream = Box::pin(stream); + crate::runtime_handle().spawn(async move { + while let Some(item) = stream.next().await { + if tx.send(item).is_err() { + break; // receiver dropped, caller stopped iterating early + } + } + }); + ConversationStreamIter(rx) +} + +/// Bridges a conversation event [`Stream`] to a Reactive-Streams-style consumer +/// with real backpressure, matching `java.util.concurrent.Flow.Subscription`'s +/// `request(n)`/`cancel()` contract. Only Java's `Flow.Publisher` exposure +/// needs this — every other binding either pulls synchronously +/// ([`conversation_stream_iter`]) or has no flow control at all +/// ([`drive_conversation_stream`]). +pub struct ConversationStreamSubscription { + demand: Arc<(AtomicU64, Notify)>, + abort: AbortHandle, +} + +impl ConversationStreamSubscription { + /// Spawn a background task that waits for demand, pulls one item at a time + /// from `stream` once demand is available, and dispatches + /// `on_next`/`on_error`/`on_complete` (each of these is expected to call + /// back into the JVM via a JNI `Subscriber` reference). + /// + /// Drains all the way to the stream's natural end rather than stopping as + /// soon as a [`ConversationStreamEvent::WorkflowFinished`] is seen — + /// against the real API, the server sometimes emits a few more + /// housekeeping events (e.g. a `chat_title_updated`-shaped + /// [`ConversationStreamEvent::Other`]) after `workflow_finished` and + /// before actually closing the connection, so stopping early would + /// silently drop them and abandon the connection while the server still + /// had something to say. + pub fn spawn(stream: S, on_next: F1, on_error: F2, on_complete: F3) -> Self + where + S: Stream> + Send + 'static, + F1: Fn(ConversationStreamEvent) + Send + Sync + 'static, + F2: FnOnce(Error) + Send + 'static, + F3: FnOnce() + Send + 'static, + { + let demand = Arc::new((AtomicU64::new(0), Notify::new())); + let demand2 = demand.clone(); + let handle = crate::runtime_handle().spawn(async move { + let mut stream = Box::pin(stream); + loop { + // wait until `request(n)` has added at least one credit + while demand2.0.load(Ordering::Acquire) == 0 { + demand2.1.notified().await; + } + match stream.next().await { + Some(Ok(event)) => { + demand2.0.fetch_sub(1, Ordering::AcqRel); + on_next(event); + } + Some(Err(err)) => { + on_error(err); + break; + } + None => { + on_complete(); + break; + } + } + } + }); + Self { + demand, + abort: handle.abort_handle(), + } + } + + /// Called from `Flow.Subscription.request(n)` (any JVM thread). + pub fn request(&self, n: u64) { + self.demand.0.fetch_add(n, Ordering::AcqRel); + self.demand.1.notify_one(); + } + + /// Called from `Flow.Subscription.cancel()`. + pub fn cancel(&self) { + self.abort.abort(); + } +} + +#[cfg(test)] +mod tests { + use futures_util::stream; + + use super::*; + use crate::agent::types::{ChatFinishedPayload, ChatStartedPayload, Interrupt}; + + // Regression test for the interrupted-run gap: + // https://github.com/longbridge/developers/pull/1176 confirms an + // interrupted run never emits `WorkflowFinished` — before this fix, + // `drive_conversation_stream` would run to the end of such a stream + // without ever setting `final_response` and return + // `Error::ConversationStreamEnded`. + #[tokio::test] + async fn drive_conversation_stream_terminates_on_human_interaction_required() { + let interrupt_resp = ConversationResponse::from_stream_interrupt( + Some(("ct_1".to_string(), "1".to_string())), + Interrupt { + node_id: "n_ask_human".to_string(), + tool_call_id: "call_1".to_string(), + questions: vec![], + message_id: 1, + chat_id: 1, + }, + ); + let events: Vec> = vec![ + Ok(ConversationStreamEvent::ChatStarted(ChatStartedPayload { + chat_uid: "ct_1".to_string(), + message_id: "1".to_string(), + })), + Ok(ConversationStreamEvent::HumanInteractionRequired( + interrupt_resp, + )), + Ok(ConversationStreamEvent::ChatFinished( + ChatFinishedPayload::default(), + )), + ]; + + let mut seen = 0; + let resp = drive_conversation_stream(stream::iter(events), |_| seen += 1) + .await + .unwrap(); + assert_eq!(seen, 3); + assert_eq!( + resp.status, + crate::agent::types::ConversationStatus::Interrupted + ); + assert!(resp.interrupt.is_some()); + } +} diff --git a/rust/src/agent/types.rs b/rust/src/agent/types.rs new file mode 100644 index 0000000000..9608fe262a --- /dev/null +++ b/rust/src/agent/types.rs @@ -0,0 +1,1188 @@ +#![allow(missing_docs)] + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Answers keyed by `tool_call_id`, each value being a map of question text to +/// answer, used as the request body of +/// [`crate::AgentContext::continue_conversation`] and +/// [`crate::AgentContext::continue_conversation_streamed`]. +pub type AnswersByToolCall = HashMap>; + +/// A Workspace the current account belongs to +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + /// Workspace ID + pub id: String, + /// Workspace name + pub name: String, + /// Creation time, Unix timestamp in seconds + #[serde(default)] + pub created_at: i64, + /// Last updated time, Unix timestamp in seconds + #[serde(default)] + pub updated_at: i64, +} + +/// Response for [`crate::AgentContext::workspaces`] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspacesResponse { + /// Workspaces the current account belongs to + pub workspaces: Vec, +} + +/// An Agent in a Workspace +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Agent { + /// Agent UID, used as the path parameter of + /// [`crate::AgentContext::conversation`] + pub uid: String, + /// Agent name + pub name: String, + /// Agent description + #[serde(default)] + pub description: String, + /// Agent mode, e.g. `chat` + #[serde(default)] + pub mode: String, + /// Icon URL + #[serde(default)] + pub icon: String, + /// Whether published; only published Agents can start conversations + #[serde(default)] + pub is_published: bool, + /// Publish time, Unix timestamp in seconds; 0 if unpublished + #[serde(default)] + pub published_at: i64, + /// Creation time, Unix timestamp in seconds + #[serde(default)] + pub created_at: i64, + /// Last updated time, Unix timestamp in seconds + #[serde(default)] + pub updated_at: i64, +} + +/// Response for [`crate::AgentContext::agents`] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentsResponse { + /// Agent list + pub agents: Vec, + /// Total number of matching Agents + #[serde(default)] + pub total: i32, +} + +/// Options for [`crate::AgentContext::agents`] +#[derive(Debug, Serialize, Default, Clone)] +pub struct GetAgentsOptions { + #[serde(skip_serializing_if = "Option::is_none")] + page: Option, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, +} + +impl GetAgentsOptions { + /// Create a new `GetAgentsOptions` + #[inline] + pub fn new() -> Self { + Default::default() + } + + /// Set the page number, starts at 1 + #[inline] + #[must_use] + pub fn page(self, page: i32) -> Self { + Self { + page: Some(page), + ..self + } + } + + /// Set the page size + #[inline] + #[must_use] + pub fn limit(self, limit: i32) -> Self { + Self { + limit: Some(limit), + ..self + } + } + + /// Fuzzy search by Agent name + #[inline] + #[must_use] + pub fn name(self, name: impl Into) -> Self { + Self { + name: Some(name.into()), + ..self + } + } +} + +/// Final run status of a conversation +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConversationStatus { + /// The run completed successfully + Succeeded, + /// The run is paused, waiting for + /// [`crate::AgentContext::continue_conversation`] + Interrupted, + /// The run failed + Failed, + /// The run was stopped + Stopped, +} + +/// A source referenced by the answer +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reference { + /// Reference index + #[serde(default)] + pub index: i32, + /// Reference title + #[serde(default)] + pub title: String, + /// Reference URL + #[serde(default)] + pub url: String, +} + +/// One question the Agent needs you to answer +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Question { + /// Question text + pub question: String, + /// Options; empty means free-form answer + #[serde(default)] + pub options: Vec, + /// Whether multiple options may be selected + #[serde(default)] + pub multi_select: bool, +} + +/// One option of a [`Question`] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuestionOption { + /// Option text + #[serde(default)] + pub description: String, +} + +/// Present when a conversation run is interrupted, waiting for +/// [`crate::AgentContext::continue_conversation`] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Interrupt { + /// ID of the node that triggered the interrupt + pub node_id: String, + /// Tool call ID of this inquiry; used as the answer key when continuing + pub tool_call_id: String, + /// Questions you need to answer + #[serde(default)] + pub questions: Vec, + /// ID of the paused message + #[serde(default)] + pub message_id: i64, + /// ID of the owning conversation + #[serde(default)] + pub chat_id: i64, +} + +/// Present when a conversation run failed +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentError { + /// Error code + #[serde(default)] + pub code: i32, + /// Error message + #[serde(default)] + pub message: String, +} + +/// Response for [`crate::AgentContext::conversation`], +/// [`crate::AgentContext::continue_conversation`], and the final result of the +/// streamed counterparts +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationResponse { + /// Conversation identifier, used for follow-up questions and + /// troubleshooting + pub chat_uid: String, + /// Message ID of this round (as a string). Accepts a raw JSON number too, + /// defensively — see [`ChatStartedPayload::message_id`]. + #[serde(deserialize_with = "crate::serde_utils::deserialize_string_or_int_as_string")] + pub message_id: String, + /// Final run status + pub status: ConversationStatus, + /// Final answer text; valid when `status` is `succeeded` + #[serde(default)] + pub answer: String, + /// Sources referenced by the answer + #[serde(default)] + pub references: Option>, + /// Run duration in seconds + #[serde(default)] + pub elapsed_time: f64, + /// Present only when `status` is `interrupted` + #[serde(default)] + pub interrupt: Option, + /// Present only when the run failed + #[serde(default)] + pub error: Option, +} + +impl ConversationResponse { + /// Build a [`ConversationResponse`] from a streamed conversation's parts — + /// `chat_uid`/`message_id` captured from an earlier `chat_started` event + /// (`None` if it was never observed) and the `workflow_finished` payload. + pub(crate) fn from_stream_parts( + started: Option<(String, String)>, + payload: WorkflowFinishedPayload, + ) -> Self { + let (chat_uid, message_id) = started.unwrap_or_default(); + let error = (payload.status == ConversationStatus::Failed).then_some(AgentError { + code: payload.error_code, + message: payload.error_message, + }); + Self { + chat_uid, + message_id, + status: payload.status, + answer: payload.outputs.answer.unwrap_or_default(), + references: payload.outputs.references, + elapsed_time: payload.elapsed_time, + interrupt: None, + error, + } + } + + /// Build a [`ConversationResponse`] from a streamed conversation's parts — + /// `chat_uid`/`message_id` captured from an earlier `chat_started` event, + /// and a `human_interaction_required` event's [`Interrupt`] payload. + /// + /// Unlike the succeeded/failed/stopped cases, an interrupted run doesn't + /// emit `workflow_finished` at all — `human_interaction_required` is the + /// terminal event of the stream instead, so this plays the same role + /// [`Self::from_stream_parts`] plays for the other outcomes. + pub(crate) fn from_stream_interrupt( + started: Option<(String, String)>, + interrupt: Interrupt, + ) -> Self { + let (chat_uid, message_id) = started.unwrap_or_default(); + Self { + chat_uid, + message_id, + status: ConversationStatus::Interrupted, + answer: String::new(), + references: None, + elapsed_time: 0.0, + interrupt: Some(interrupt), + error: None, + } + } +} + +/// Payload of a `chat_started` SSE event +#[derive(Debug, Clone, Deserialize)] +pub struct ChatStartedPayload { + /// Conversation identifier + pub chat_uid: String, + /// Message ID of this round. The docs' SSE example shows this as a raw JSON + /// number here (unlike the blocking response's top-level `message_id`, + /// which is a quoted string) — accept either. + #[serde(deserialize_with = "crate::serde_utils::deserialize_string_or_int_as_string")] + pub message_id: String, +} + +/// Payload of a `message` SSE event — an incremental text chunk. This is the +/// highest-frequency event; concatenate `text` fragments in arrival order. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MessagePayload { + /// Incremental text fragment + #[serde(default)] + pub text: String, + /// `answer` — final answer text; `think` — reasoning process; `process` + /// — stage progress description + #[serde(default, rename = "type")] + pub message_type: String, + /// Identifier of the stream segment this fragment belongs to. Fragments + /// with the same `key` form one continuous block — group by `key` when + /// rendering + #[serde(default)] + pub key: String, + /// Time this segment started, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// Stage identifier; only present when `message_type` is `"process"` + #[serde(default)] + pub stage: String, + /// Stage title while running; only present when `message_type` is + /// `"process"` + #[serde(default)] + pub stage_title: String, + /// Stage title after it finishes; only present when `message_type` is + /// `"process"` + #[serde(default)] + pub stage_finished_title: String, + /// Extra payload attached to the fragment; usually absent + #[serde(default)] + pub outputs: Option, +} + +/// `outputs` of a `workflow_finished` SSE event +#[derive(Debug, Clone, Default, Deserialize)] +pub struct WorkflowOutputs { + /// Final answer text; present when the run succeeded + #[serde(default)] + pub answer: Option, + /// Sources referenced by the answer + #[serde(default)] + pub references: Option>, +} + +/// Payload of a `workflow_finished` SSE event. `status` is never +/// `interrupted` here — an interrupted run doesn't emit `workflow_finished` +/// at all; see [`ConversationStreamEvent::HumanInteractionRequired`]. +#[derive(Debug, Clone, Deserialize)] +pub struct WorkflowFinishedPayload { + /// Final run status: `succeeded` / `failed` / `stopped` + pub status: ConversationStatus, + /// Run duration in seconds + #[serde(default)] + pub elapsed_time: f64, + /// Run outputs + #[serde(default)] + pub outputs: WorkflowOutputs, + /// Localized error description; only present when `status` is `failed` + #[serde(default)] + pub error: String, + /// Error code; only present when `status` is `failed` + #[serde(default)] + pub error_code: i32, + /// User-facing error message; only present on failure + #[serde(default)] + pub error_message: String, + /// Extra error context (e.g. `workflow_run_id`); may be omitted + #[serde(default)] + pub error_args: Option, + /// Process stages the run went through; for display only + #[serde(default)] + pub process_data: Vec, +} + +/// `inputs` of a `workflow_started` SSE event +#[derive(Debug, Clone, Default, Deserialize)] +pub struct WorkflowStartedInputs { + /// ID of the owning conversation + #[serde(default)] + pub chat_id: i64, + /// Conversation identifier + #[serde(default)] + pub chat_uid: String, + /// Message ID of this round (observed as a raw JSON number; accepts a + /// string too, see [`ChatStartedPayload::message_id`]) + #[serde( + default, + deserialize_with = "crate::serde_utils::deserialize_string_or_int_as_string" + )] + pub message_id: String, + /// The question that was asked + #[serde(default)] + pub query: String, +} + +/// Payload of a `workflow_started` SSE event, observed right after +/// `chat_started` +#[derive(Debug, Clone, Default, Deserialize)] +pub struct WorkflowStartedPayload { + /// Whether this run's answer was served from a cache + #[serde(default)] + pub hit_cache: bool, + /// Echoes the run's inputs + #[serde(default)] + pub inputs: WorkflowStartedInputs, + /// Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// Internal workflow run ID + #[serde(default)] + pub workflow_id: i64, +} + +/// Payload of a `chat_finished` SSE event, observed once all `message` events +/// for this round have been sent, shortly before `workflow_finished` +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ChatFinishedPayload { + /// ID of the owning conversation + #[serde(default)] + pub chat_id: i64, + /// Conversation identifier + #[serde(default)] + pub chat_uid: String, + /// Message ID of this round (observed as a raw JSON number; accepts a + /// string too, see [`ChatStartedPayload::message_id`]) + #[serde( + default, + deserialize_with = "crate::serde_utils::deserialize_string_or_int_as_string" + )] + pub message_id: String, + /// Error detail; empty on success + #[serde(default)] + pub error: String, + /// User-facing error message; empty on success + #[serde(default)] + pub error_message: String, +} + +/// Payload of a `chat_title_updated` SSE event — the server auto-generates a +/// short title for the conversation as a UI convenience. Can arrive before +/// *or* after `workflow_finished`; not tied to the run's outcome. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ChatTitleUpdatedPayload { + /// ID of the owning conversation + #[serde(default)] + pub chat_id: i64, + /// Conversation identifier + #[serde(default)] + pub chat_uid: String, + /// Where the title came from, e.g. `"ai_generated"` + #[serde(default)] + pub source: String, + /// The new (possibly truncated) title + #[serde(default)] + pub title: String, + /// Unix timestamp in seconds + #[serde(default)] + pub updated_at: i64, +} + +/// Payload of a `thinking_started` SSE event — the Agent has entered the +/// reasoning phase (analyzing the question, planning tool calls). Between +/// this and [`ConversationStreamEvent::ThinkingFinished`], `Message` events +/// with `message_type == "think"` and tool-call events may arrive. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ThinkingStartedPayload { + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, +} + +/// Payload of a `thinking_finished` SSE event — the reasoning phase is over; +/// answer text (`Message` with `message_type == "answer"`) follows. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ThinkingFinishedPayload { + /// Finish time, Unix timestamp in seconds + #[serde(default)] + pub finished_at: i64, + /// Reasoning duration in seconds + #[serde(default)] + pub elapsed_time: i32, +} + +/// Payload of a `node_tool_use_started` SSE event — an ordinary tool call has +/// started. Match it to its `NodeToolUseFinished` counterpart by +/// `tool_use_id`. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct NodeToolUseStartedPayload { + /// Unique ID of this call; matches the finished event + #[serde(default)] + pub tool_use_id: String, + /// Localized display name of the tool + #[serde(default)] + pub tool_name: String, + /// Locale-stable tool identifier; use this for logic keyed on the tool + /// kind + #[serde(default)] + pub tool_func_name: String, + /// Call arguments as a JSON string + #[serde(default)] + pub tool_args: String, + /// Progress text suitable for direct display, e.g. `"Searching the + /// web…"` + #[serde(default)] + pub tips: String, + /// Short tags accompanying `tips`; may be omitted + #[serde(default)] + pub tip_chips: Vec, + /// Round number. Calls in the same round (same `iteration`) run in + /// parallel + #[serde(default)] + pub iteration: i32, + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, +} + +/// `outputs` of a [`NodeToolUseFinishedPayload`] — only carries fields meant +/// for display +#[derive(Debug, Clone, Default, Deserialize)] +pub struct NodeToolUseOutputs { + /// Sources referenced by the tool result + #[serde(default)] + pub references: Option>, + /// Domains of the referenced sources + #[serde(default)] + pub reference_domains: Option>, + /// The query the tool executed + #[serde(default)] + pub query: Option, + /// Raw response text of the tool + #[serde(default)] + pub text: Option, + /// Parsed request arguments + #[serde(default)] + pub tool_args: Option, + /// Structured result; present only for selected tools + #[serde(default)] + pub data: Option, +} + +/// Payload of a `node_tool_use_finished` SSE event — the tool call has +/// ended. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct NodeToolUseFinishedPayload { + /// Matches the `tool_use_id` of the started event + #[serde(default)] + pub tool_use_id: String, + /// `succeeded` / `failed` + #[serde(default)] + pub status: String, + /// Error description on failure + #[serde(default)] + pub error: String, + /// Call duration in seconds + #[serde(default)] + pub elapsed_time: f64, + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// Localized display name + #[serde(default)] + pub tool_name: String, + /// Locale-stable tool identifier + #[serde(default)] + pub tool_func_name: String, + /// Call arguments as a JSON string + #[serde(default)] + pub tool_args: String, + /// Tool category + #[serde(default)] + pub tool_type: String, + /// Progress text + #[serde(default)] + pub tips: String, + /// Short tags; may be omitted + #[serde(default)] + pub tip_chips: Vec, + /// Round number + #[serde(default)] + pub iteration: i32, + /// `true` if the call happened during the thinking phase + #[serde(default)] + pub is_thinking: bool, + /// Filtered call results, for display + #[serde(default)] + pub outputs: NodeToolUseOutputs, +} + +/// Payload of a `subagent_started` SSE event. When the Agent spawns a +/// subagent to work on a sub-task, the subagent's lifecycle is reported with +/// this dedicated event family instead of `node_tool_use_*`. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SubagentStartedPayload { + /// ID of the node that spawned the subagent + #[serde(default)] + pub node_id: String, + /// Unique ID of this spawn; matches the finished event + #[serde(default)] + pub tool_use_id: String, + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// Goal assigned to the subagent + #[serde(default)] + pub goal: String, + /// Full task prompt given to the subagent + #[serde(default)] + pub prompt: String, + /// Subagent identifier; may be omitted + #[serde(default)] + pub subagent_id: String, + /// Tools granted to the subagent; may be omitted + #[serde(default)] + pub tools: Vec, +} + +/// Payload of a `subagent_progress` SSE event, emitted every time the +/// subagent calls one of its own tools. Use it to render a live timeline +/// inside the subagent card. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SubagentProgressPayload { + /// ID of the node that spawned the subagent + #[serde(default)] + pub node_id: String, + /// `tool_use_id` of the owning `SubagentStarted` event + #[serde(default)] + pub parent_tool_call_id: String, + /// Name of the tool the subagent called + #[serde(default)] + pub subagent_tool_name: String, + /// Arguments of that call, as a JSON string + #[serde(default)] + pub subagent_tool_args: String, + /// Status of that call: `running` / `succeeded` / `failed` + #[serde(default)] + pub subagent_status: String, + /// Duration of that call in milliseconds + #[serde(default)] + pub subagent_duration_ms: i64, + /// The subagent's internal round number + #[serde(default)] + pub subagent_iteration: i32, + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, +} + +/// `outputs` of a [`SubagentFinishedPayload`] +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SubagentOutputs { + /// The goal that was assigned to the subagent + #[serde(default)] + pub goal: Option, + /// The subagent's result + #[serde(default)] + pub result: Option, + /// Timeline of tool calls the subagent made + #[serde(default)] + pub subagent_tools: Option>, +} + +/// Payload of a `subagent_finished` SSE event +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SubagentFinishedPayload { + /// ID of the node that spawned the subagent + #[serde(default)] + pub node_id: String, + /// Matches the `tool_use_id` of `SubagentStarted` + #[serde(default)] + pub tool_use_id: String, + /// `succeeded` / `failed` + #[serde(default)] + pub status: String, + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// Total subagent duration in seconds + #[serde(default)] + pub elapsed_time: f64, + /// Error description on failure + #[serde(default)] + pub error: String, + /// Subagent result: `goal`, `result`, and the timeline of tool calls it + /// made + #[serde(default)] + pub outputs: SubagentOutputs, +} + +/// Payload of an `agent_tool_started` SSE event. When the Agent delegates to +/// another Agent as a tool, that inner run is reported with the +/// `agent_tool_*` family — the shape mirrors the subagent events. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AgentToolStartedPayload { + /// ID of the calling node + #[serde(default)] + pub node_id: String, + /// Unique ID of this call; matches the finished event + #[serde(default)] + pub tool_use_id: String, + /// Identifier of the Agent being called + #[serde(default)] + pub agent_tool_name: String, + /// Display title; may be omitted + #[serde(default)] + pub title: String, + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// Call arguments as a JSON string + #[serde(default)] + pub tool_args: String, + /// Localized display name + #[serde(default)] + pub tool_name: String, + /// Progress text; may be omitted + #[serde(default)] + pub tips: String, + /// Short tags; may be omitted + #[serde(default)] + pub tip_chips: Vec, + /// `true` if called during the thinking phase + #[serde(default)] + pub is_thinking: bool, +} + +/// Payload of an `agent_tool_progress` SSE event, emitted for each inner +/// tool call the delegated Agent makes. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AgentToolProgressPayload { + /// ID of the calling node + #[serde(default)] + pub node_id: String, + /// `tool_use_id` of the owning `AgentToolStarted` event + #[serde(default)] + pub parent_tool_call_id: String, + /// Identifier of the Agent being called + #[serde(default)] + pub agent_tool_name: String, + /// Name of the inner tool the delegated Agent called + #[serde(default)] + pub inner_tool_name: String, + /// Arguments of that inner call, as a JSON string + #[serde(default)] + pub inner_tool_args: String, + /// Status of the inner call: `running` / `succeeded` / `failed` + #[serde(default)] + pub status: String, + /// Duration of the inner call in milliseconds + #[serde(default)] + pub duration_ms: i64, + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// `true` if during the thinking phase + #[serde(default)] + pub is_thinking: bool, +} + +/// Payload of an `agent_tool_finished` SSE event +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AgentToolFinishedPayload { + /// ID of the calling node + #[serde(default)] + pub node_id: String, + /// Matches the `tool_use_id` of `AgentToolStarted` + #[serde(default)] + pub tool_use_id: String, + /// Identifier of the Agent being called + #[serde(default)] + pub agent_tool_name: String, + /// `succeeded` / `failed` + #[serde(default)] + pub status: String, + /// Start time, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// Total duration in seconds + #[serde(default)] + pub elapsed_time: f64, + /// Error description on failure + #[serde(default)] + pub error: String, + /// Call arguments as a JSON string + #[serde(default)] + pub tool_args: String, + /// Result of the delegated Agent + #[serde(default)] + pub outputs: Option, + /// Tool category + #[serde(default)] + pub tool_type: String, + /// Progress text; may be omitted + #[serde(default)] + pub tips: String, + /// Short tags; may be omitted + #[serde(default)] + pub tip_chips: Vec, + /// `true` if during the thinking phase + #[serde(default)] + pub is_thinking: bool, +} + +/// Payload of a `query_masked` SSE event — sensitive content in the user +/// query was masked before processing. Display `masked_query` instead of the +/// original query. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct QueryMaskedPayload { + /// The original user query + #[serde(default)] + pub raw_query: String, + /// The masked query + #[serde(default)] + pub masked_query: String, +} + +/// Payload of a `plan_changed` SSE event — the Agent created or updated its +/// task plan. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PlanChangedPayload { + /// ID of the planning node + #[serde(default)] + pub node_id: String, + /// Time of the change, Unix timestamp in seconds + #[serde(default)] + pub started_at: i64, + /// The current plan content + #[serde(default)] + pub outputs: Option, + /// Identifies the planning tool. Carried as a top-level sibling of + /// `data` in the raw SSE envelope rather than inside `data` itself. + #[serde(default)] + pub tool_name: String, +} + +/// Payload of a `context_compress_started` SSE event, marking the start of a +/// context-compression pass triggered by a long conversation. Unlike other +/// events, the timestamp here is an RFC 3339 string. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ContextCompressStartedPayload { + /// Start time, RFC 3339 + #[serde(default)] + pub started_at: String, + /// Compression input summary + #[serde(default)] + pub inputs: Option, +} + +/// Payload of a `context_compress_finished` SSE event. Unlike other events, +/// the timestamp here is an RFC 3339 string. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ContextCompressFinishedPayload { + /// Finish time, RFC 3339 + #[serde(default)] + pub created_at: String, + /// Compression input summary + #[serde(default)] + pub inputs: Option, + /// Compression result summary + #[serde(default)] + pub outputs: Option, +} + +/// One event observed while streaming +/// [`crate::AgentContext::conversation_streamed`] +/// or [`crate::AgentContext::continue_conversation_streamed`]. +/// +/// A run always begins with `ChatStarted` and ends with `ChatFinished`. What +/// happens in between depends on the outcome: +/// +/// - Succeeded: `ChatStarted` → `WorkflowStarted` → `ThinkingStarted` → +/// `Message` (`message_type == "think"`) … → `NodeToolUseStarted` / +/// `NodeToolUseFinished` … → `ThinkingFinished` → `Message` (`message_type == +/// "answer"`) … → `WorkflowFinished` (`status == "succeeded"`) → +/// `ChatFinished` +/// - Interrupted (the Agent needs your input; resume via +/// [`crate::AgentContext::continue_conversation_streamed`]): `ChatStarted` → +/// `WorkflowStarted` → … → `HumanInteractionRequired` → `ChatFinished`. An +/// interrupted run does **not** emit `WorkflowFinished`, and resuming it does +/// **not** emit `WorkflowStarted` again. +/// - Failed: `ChatStarted` → `WorkflowStarted` → … → `WorkflowFinished` +/// (`status == "failed"`) → `ChatFinished` +/// +/// For a plain question-and-answer integration you only need to handle four +/// variants — everything else is optional progress display: `Message` with +/// `message_type == "answer"` (append `text` to the answer being displayed), +/// `HumanInteractionRequired` (show the questions and call +/// `continue_conversation`/`continue_conversation_streamed` with the +/// answers), `WorkflowFinished` (read the final outcome), and `ChatFinished` +/// (the stream is over). +#[derive(Debug, Clone)] +pub enum ConversationStreamEvent { + /// The run has started + ChatStarted(ChatStartedPayload), + /// Observed right after `ChatStarted` on every run seen so far, see + /// [`WorkflowStartedPayload`]'s docs. Not emitted when resuming an + /// interrupted run. + WorkflowStarted(WorkflowStartedPayload), + /// An incremental piece of the answer + Message(MessagePayload), + /// A heartbeat with no payload, observed at arbitrary points in the + /// stream (including in between `Message` chunks) + Ping, + /// The Agent has entered the reasoning phase + ThinkingStarted(ThinkingStartedPayload), + /// The reasoning phase is over + ThinkingFinished(ThinkingFinishedPayload), + /// An ordinary tool call has started + NodeToolUseStarted(NodeToolUseStartedPayload), + /// An ordinary tool call has ended + NodeToolUseFinished(NodeToolUseFinishedPayload), + /// The Agent has spawned a subagent to work on a sub-task + SubagentStarted(SubagentStartedPayload), + /// The subagent has called one of its own tools + SubagentProgress(SubagentProgressPayload), + /// The subagent has finished its sub-task + SubagentFinished(SubagentFinishedPayload), + /// The Agent has delegated to another Agent as a tool + AgentToolStarted(AgentToolStartedPayload), + /// The delegated Agent has called one of its own tools + AgentToolProgress(AgentToolProgressPayload), + /// The delegated Agent's run has finished + AgentToolFinished(AgentToolFinishedPayload), + /// The run is paused: the Agent needs more information or confirmation + /// from you, carrying the interrupt to resume from via + /// [`crate::AgentContext::continue_conversation_streamed`]. Unlike + /// `WorkflowFinished`, this is emitted instead of (never alongside) + /// `WorkflowFinished` for the same run. + HumanInteractionRequired(ConversationResponse), + /// Sensitive content in the user query was masked before processing + QueryMasked(QueryMaskedPayload), + /// The Agent created or updated its task plan + PlanChanged(PlanChangedPayload), + /// A context-compression pass has started (long conversations trigger + /// this) + ContextCompressStarted(ContextCompressStartedPayload), + /// The context-compression pass has finished + ContextCompressFinished(ContextCompressFinishedPayload), + /// Observed once all `Message` events for this round have been sent, see + /// [`ChatFinishedPayload`]'s docs + ChatFinished(ChatFinishedPayload), + /// The run finished successfully, with a failure, or stopped by the + /// user, carrying the run's outcome. Never emitted for an interrupted + /// run — see [`ConversationStreamEvent::HumanInteractionRequired`] for + /// that case. Not necessarily the last event of the stream — the server + /// may still emit a few more housekeeping events (e.g. + /// [`ConversationStreamEvent::ChatTitleUpdated`]) before actually + /// closing the connection. + WorkflowFinished(ConversationResponse), + /// The server auto-generating a short title for the conversation, see + /// [`ChatTitleUpdatedPayload`]'s docs. Can arrive before *or* after + /// [`ConversationStreamEvent::WorkflowFinished`]. + ChatTitleUpdated(ChatTitleUpdatedPayload), + /// An event type not recognized by this SDK version, carried as raw JSON + /// so callers aren't broken by future additions to the API. `event` is + /// the SSE envelope's discriminator string, so callers can at least tell + /// these apart instead of getting an opaque blob. + Other { + /// The SSE envelope's `event` field (the event type name) + event: String, + /// The SSE envelope's `data` field + data: serde_json::Value, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + // The `data` payload of the "Run succeeded" example from + // https://open.longbridge.com/en/docs/ai/chat/conversation + const SUCCEEDED_JSON: &str = r#"{ + "chat_uid": "ct_9f2c1a5b", + "message_id": "42", + "status": "succeeded", + "answer": "Tesla (TSLA.US) recently...", + "references": [ + { "index": 1, "title": "...", "url": "..." } + ], + "elapsed_time": 3.21 + }"#; + + // The `data` payload of the "Run interrupted" example from the same page. + const INTERRUPTED_JSON: &str = r#"{ + "chat_uid": "ct_9f2c1a5b", + "message_id": "43", + "status": "interrupted", + "answer": "", + "references": null, + "elapsed_time": 1.05, + "interrupt": { + "node_id": "n_ask_human", + "tool_call_id": "call_abc123", + "questions": [ + { + "question": "Which time range would you like to check?", + "options": [ + { "description": "Past week" }, + { "description": "Past month" } + ], + "multi_select": false + } + ], + "message_id": 43, + "chat_id": 1001 + } + }"#; + + #[test] + fn deserialize_succeeded_conversation_response() { + let resp: ConversationResponse = serde_json::from_str(SUCCEEDED_JSON).unwrap(); + assert_eq!(resp.chat_uid, "ct_9f2c1a5b"); + assert_eq!(resp.message_id, "42"); + assert_eq!(resp.status, ConversationStatus::Succeeded); + assert_eq!(resp.answer, "Tesla (TSLA.US) recently..."); + assert_eq!(resp.references.as_ref().unwrap().len(), 1); + assert_eq!(resp.references.as_ref().unwrap()[0].index, 1); + assert!((resp.elapsed_time - 3.21).abs() < f64::EPSILON); + assert!(resp.interrupt.is_none()); + assert!(resp.error.is_none()); + } + + #[test] + fn deserialize_interrupted_conversation_response() { + let resp: ConversationResponse = serde_json::from_str(INTERRUPTED_JSON).unwrap(); + assert_eq!(resp.status, ConversationStatus::Interrupted); + let interrupt = resp.interrupt.expect("interrupt"); + assert_eq!(interrupt.node_id, "n_ask_human"); + assert_eq!(interrupt.tool_call_id, "call_abc123"); + assert_eq!(interrupt.message_id, 43); + assert_eq!(interrupt.chat_id, 1001); + assert_eq!(interrupt.questions.len(), 1); + assert_eq!(interrupt.questions[0].options.len(), 2); + assert!(!interrupt.questions[0].multi_select); + } + + #[test] + fn deserialize_chat_started_payload_with_numeric_message_id() { + // The SSE example's `chat_started` event encodes `message_id` as a raw + // JSON number, unlike the blocking response's quoted string. + let json = r#"{"chat_uid":"ct_9f2c1a5b","message_id":42}"#; + let payload: ChatStartedPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.chat_uid, "ct_9f2c1a5b"); + assert_eq!(payload.message_id, "42"); + } + + #[test] + fn deserialize_message_payload() { + let json = r#"{"text":"Tesla"}"#; + let payload: MessagePayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.text, "Tesla"); + } + + #[test] + fn deserialize_message_payload_with_full_fields() { + // https://github.com/longbridge/developers/pull/1176 + let json = + r#"{"text":"Tesla","type":"answer","key":"n_llm_1:answer","started_at":1752048000}"#; + let payload: MessagePayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.text, "Tesla"); + assert_eq!(payload.message_type, "answer"); + assert_eq!(payload.key, "n_llm_1:answer"); + assert_eq!(payload.started_at, 1752048000); + } + + #[test] + fn deserialize_workflow_finished_payload() { + let json = r#"{"status":"succeeded","elapsed_time":3.21,"outputs":{"answer":"Tesla (TSLA.US) recently..."}}"#; + let payload: WorkflowFinishedPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.status, ConversationStatus::Succeeded); + assert!((payload.elapsed_time - 3.21).abs() < f64::EPSILON); + assert_eq!( + payload.outputs.answer.as_deref(), + Some("Tesla (TSLA.US) recently...") + ); + + let resp = ConversationResponse::from_stream_parts( + Some(("ct_9f2c1a5b".to_string(), "42".to_string())), + payload, + ); + assert_eq!(resp.chat_uid, "ct_9f2c1a5b"); + assert_eq!(resp.message_id, "42"); + assert_eq!(resp.answer, "Tesla (TSLA.US) recently..."); + assert!(resp.interrupt.is_none()); + assert!(resp.error.is_none()); + } + + #[test] + fn deserialize_workflow_finished_payload_with_failure() { + // Error info is top-level on the event, not nested under `outputs` + // (unlike the blocking response's `ConversationResponse.error`). + let json = r#"{"status":"failed","elapsed_time":0.8,"error":"upstream timeout","error_code":500,"error_message":"Something went wrong, please try again"}"#; + let payload: WorkflowFinishedPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.status, ConversationStatus::Failed); + assert_eq!(payload.error, "upstream timeout"); + assert_eq!(payload.error_code, 500); + assert_eq!( + payload.error_message, + "Something went wrong, please try again" + ); + + let resp = ConversationResponse::from_stream_parts(None, payload); + assert_eq!(resp.status, ConversationStatus::Failed); + let error = resp.error.expect("error"); + assert_eq!(error.code, 500); + assert_eq!(error.message, "Something went wrong, please try again"); + } + + #[test] + fn conversation_response_from_stream_interrupt() { + // An interrupted run never emits `workflow_finished` — the + // `human_interaction_required` event is the terminal one instead, + // and carries an `Interrupt` shaped identically to the blocking + // response's `interrupt` field. + let json = r#"{"node_id":"n_ask_human","tool_call_id":"call_abc123","questions":[{"question":"Which time range would you like to check?","options":[{"description":"Past week"},{"description":"Past month"}],"multi_select":false}],"message_id":43,"chat_id":1001}"#; + let interrupt: Interrupt = serde_json::from_str(json).unwrap(); + + let resp = ConversationResponse::from_stream_interrupt( + Some(("ct_9f2c1a5b".to_string(), "43".to_string())), + interrupt, + ); + assert_eq!(resp.chat_uid, "ct_9f2c1a5b"); + assert_eq!(resp.message_id, "43"); + assert_eq!(resp.status, ConversationStatus::Interrupted); + let interrupt = resp.interrupt.expect("interrupt"); + assert_eq!(interrupt.node_id, "n_ask_human"); + assert_eq!(interrupt.questions.len(), 1); + } + + #[test] + fn deserialize_node_tool_use_finished_payload() { + let json = r#"{"tool_use_id":"call_abc123","status":"succeeded","elapsed_time":1.42,"tool_name":"Web Search","tool_func_name":"web_search","tool_args":"{\"query\":\"TSLA stock news\"}","tool_type":"builtin","tips":"Searched the web","iteration":1,"is_thinking":true,"outputs":{"query":"TSLA stock news","references":[{"index":1,"title":"...","url":"..."}]}}"#; + let payload: NodeToolUseFinishedPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.tool_use_id, "call_abc123"); + assert_eq!(payload.status, "succeeded"); + assert_eq!(payload.tool_func_name, "web_search"); + assert!(payload.is_thinking); + assert_eq!(payload.outputs.query.as_deref(), Some("TSLA stock news")); + assert_eq!(payload.outputs.references.as_ref().unwrap().len(), 1); + } + + #[test] + fn deserialize_plan_changed_payload_picks_up_sibling_tool_name() { + let mut payload: PlanChangedPayload = + serde_json::from_str(r#"{"node_id":"n_plan","started_at":1752048000}"#).unwrap(); + // `tool_name` lives outside `data` in the raw envelope; simulated + // here the same way `map_conversation_event` fills it in. + payload.tool_name = "planner".to_string(); + assert_eq!(payload.node_id, "n_plan"); + assert_eq!(payload.tool_name, "planner"); + } + + #[test] + fn deserialize_workspaces_response() { + let json = r#"{ + "workspaces": [ + { "id": "1001", "name": "My Workspace", "created_at": 1742000000, "updated_at": 1742001000 } + ] + }"#; + let resp: WorkspacesResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.workspaces.len(), 1); + assert_eq!(resp.workspaces[0].id, "1001"); + } + + #[test] + fn deserialize_agents_response() { + let json = r#"{ + "agents": [ + { + "uid": "ag_7d3f9b2c", + "name": "US Stock Analyst", + "description": "Answers US stock questions with market and fundamental data", + "mode": "chat", + "icon": "https://cdn.longbridge.com/icons/agent.png", + "is_published": true, + "published_at": 1742000000, + "created_at": 1741000000, + "updated_at": 1742001000 + } + ], + "total": 12 + }"#; + let resp: AgentsResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.total, 12); + assert_eq!(resp.agents[0].uid, "ag_7d3f9b2c"); + assert!(resp.agents[0].is_published); + } +} diff --git a/rust/src/blocking/agent.rs b/rust/src/blocking/agent.rs new file mode 100644 index 0000000000..d4a03392ad --- /dev/null +++ b/rust/src/blocking/agent.rs @@ -0,0 +1,114 @@ +use std::{pin::Pin, sync::Arc}; + +use futures_util::Stream; +use tokio::sync::mpsc; + +use crate::{ + Config, Result, + agent::{self, AgentContext, types::*}, + blocking::runtime::BlockingRuntime, +}; + +/// Blocking AI Agent conversation context. +pub struct AgentContextSync { + rt: BlockingRuntime, +} + +impl AgentContextSync { + /// Create an [`AgentContextSync`] + pub fn new(config: Arc) -> Result { + let rt = BlockingRuntime::try_new( + move || { + let ctx = AgentContext::new(config); + let (tx, rx) = mpsc::unbounded_channel::(); + std::mem::forget(tx); + Ok::<_, crate::Error>((ctx, rx)) + }, + |_: std::convert::Infallible| {}, + )?; + Ok(Self { rt }) + } + + /// List the Workspaces the current account belongs to. + pub fn workspaces(&self) -> Result { + self.rt + .call(move |ctx| async move { ctx.workspaces().await }) + } + + /// List the Agents in the specified Workspace. + pub fn agents( + &self, + workspace_id: impl Into + Send + 'static, + opts: impl Into> + Send + 'static, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.agents(workspace_id, opts).await }) + } + + /// Start a conversation with the specified Agent, blocking until the run + /// succeeds, is interrupted, or fails. + pub fn conversation( + &self, + agent_id: impl Into + Send + 'static, + query: impl Into + Send + 'static, + chat_uid: Option, + ) -> Result { + self.rt + .call(move |ctx| async move { ctx.conversation(agent_id, query, chat_uid).await }) + } + + /// Resume an interrupted conversation, blocking until the run succeeds, is + /// interrupted again, or fails. + pub fn continue_conversation( + &self, + agent_id: impl Into + Send + 'static, + chat_uid: impl Into + Send + 'static, + message_id: impl Into + Send + 'static, + answers: AnswersByToolCall, + ) -> Result { + self.rt.call(move |ctx| async move { + ctx.continue_conversation(agent_id, chat_uid, message_id, answers) + .await + }) + } + + /// Start a conversation with the specified Agent, returning a blocking + /// [`agent::ConversationStreamIter`] of run-progress events. + pub fn conversation_streamed( + &self, + agent_id: impl Into + Send + 'static, + query: impl Into + Send + 'static, + chat_uid: Option, + ) -> Result { + let stream = self.rt.call(move |ctx| async move { + // Box the RPIT stream so it can flow through `rt.call`'s generic + // `R: Send + 'static`. + Ok( + Box::pin(ctx.conversation_streamed(agent_id, query, chat_uid).await?) + as Pin> + Send>>, + ) + })?; + Ok(agent::conversation_stream_iter(stream)) + } + + /// Resume an interrupted conversation, returning a blocking + /// [`agent::ConversationStreamIter`] of run-progress events. + pub fn continue_conversation_streamed( + &self, + agent_id: impl Into + Send + 'static, + chat_uid: impl Into + Send + 'static, + message_id: impl Into + Send + 'static, + answers: AnswersByToolCall, + ) -> Result { + let stream = self.rt.call(move |ctx| async move { + Ok(Box::pin( + ctx.continue_conversation_streamed(agent_id, chat_uid, message_id, answers) + .await?, + ) + as Pin< + Box> + Send>, + >) + })?; + Ok(agent::conversation_stream_iter(stream)) + } +} diff --git a/rust/src/blocking/mod.rs b/rust/src/blocking/mod.rs index 82f70862f0..d032229576 100644 --- a/rust/src/blocking/mod.rs +++ b/rust/src/blocking/mod.rs @@ -1,5 +1,6 @@ //! Longbridge OpenAPI SDK blocking API +mod agent; mod alert; mod asset; mod calendar; @@ -15,6 +16,7 @@ mod screener; mod sharelist; mod trade; +pub use agent::AgentContextSync; pub use alert::AlertContextSync; pub use asset::AssetContextSync; pub use calendar::CalendarContextSync; diff --git a/rust/src/error.rs b/rust/src/error.rs index 112bdb5718..84edabfd84 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -71,6 +71,10 @@ pub enum Error { /// OAuth error #[error("oauth error: {0}")] OAuth(String), + + /// A conversation event stream ended before a final result was observed + #[error("conversation stream ended before a final result was observed")] + ConversationStreamEnded, } impl Error { @@ -130,7 +134,8 @@ impl Error { | Error::ParseField { .. } | Error::UnknownCommand(_) | Error::HttpClient(_) - | Error::WsClient(_) => SimpleError::Other(self.to_string()), + | Error::WsClient(_) + | Error::ConversationStreamEnded => SimpleError::Other(self.to_string()), #[cfg(feature = "blocking")] Error::Blocking(_) => SimpleError::Other(self.to_string()), Error::OAuth(msg) => SimpleError::OAuth(msg), diff --git a/rust/src/lib.rs b/rust/src/lib.rs index c62b8805fb..3799a2c4b2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -23,6 +23,7 @@ pub use utils::counter; pub mod blocking; pub use longbridge_oauth as oauth; +pub mod agent; pub mod alert; pub mod asset; pub mod calendar; @@ -36,6 +37,7 @@ pub mod screener; pub mod sharelist; pub mod trade; +pub use agent::AgentContext; pub use alert::AlertContext; pub use asset::AssetContext; pub use calendar::CalendarContext;