Skip to content

Commit 5a6f054

Browse files
committed
[WIP] Generative UI example app
1 parent af67a71 commit 5a6f054

File tree

10 files changed

+347
-17
lines changed

10 files changed

+347
-17
lines changed

rust-sdk/crates/ag-ui-client/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ tokio = { version = "1.36.0", features = ["full"] }
2323

2424
[[example]]
2525
name = "http-agent"
26-
path = "examples/http_agent.rs"
26+
path = "examples/basic_agent.rs"
File renamed without changes.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
use std::error::Error;
2+
use std::fmt::{Debug, Display, Formatter};
3+
use std::sync::Arc;
4+
5+
use async_trait::async_trait;
6+
use log::info;
7+
use reqwest::header::HeaderMap;
8+
use reqwest::Url;
9+
use serde::{Deserialize, Serialize};
10+
11+
use ag_ui_core::{AgentState, FwdProps, JsonValue};
12+
use ag_ui_core::event::{StateDeltaEvent, StateSnapshotEvent};
13+
use ag_ui_core::types::ids::MessageId;
14+
use ag_ui_core::types::message::Message;
15+
use ag_ui_client::agent::{AgentError, AgentStateMutation, RunAgentParams};
16+
use ag_ui_client::{Agent, HttpAgent};
17+
use ag_ui_client::subscriber::{AgentSubscriber, AgentSubscriberParams};
18+
19+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
20+
pub enum StepStatus {
21+
#[serde(rename = "pending")]
22+
Pending,
23+
#[serde(rename = "completed")]
24+
Completed,
25+
}
26+
27+
impl Display for StepStatus {
28+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
29+
match self {
30+
StepStatus::Pending => write!(f, "pending"),
31+
StepStatus::Completed => write!(f, "completed"),
32+
}
33+
}
34+
}
35+
36+
impl Default for StepStatus {
37+
fn default() -> Self {
38+
StepStatus::Pending
39+
}
40+
}
41+
42+
#[derive(Serialize, Deserialize, Debug, Clone)]
43+
pub struct Step {
44+
pub description: String,
45+
#[serde(default)]
46+
pub status: StepStatus,
47+
}
48+
49+
impl Step {
50+
pub fn new(description: String) -> Self {
51+
Self {
52+
description,
53+
status: StepStatus::Pending,
54+
}
55+
}
56+
}
57+
58+
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
59+
pub struct Plan {
60+
#[serde(default)]
61+
pub steps: Vec<Step>,
62+
}
63+
64+
impl AgentState for Plan {}
65+
66+
pub struct GenerativeUiSubscriber;
67+
68+
impl GenerativeUiSubscriber {
69+
pub fn new() -> Self {
70+
Self
71+
}
72+
}
73+
74+
#[async_trait]
75+
impl<FwdPropsT> AgentSubscriber<Plan, FwdPropsT> for GenerativeUiSubscriber
76+
where
77+
FwdPropsT: FwdProps + Debug,
78+
{
79+
async fn on_state_snapshot_event(
80+
&self,
81+
event: &StateSnapshotEvent<Plan>,
82+
_params: AgentSubscriberParams<'async_trait, Plan, FwdPropsT>,
83+
) -> Result<AgentStateMutation<Plan>, AgentError> {
84+
info!("📸 State snapshot received:");
85+
let plan = &event.snapshot;
86+
info!(" Plan with {} steps:", plan.steps.len());
87+
for (i, step) in plan.steps.iter().enumerate() {
88+
let status_icon = match step.status {
89+
StepStatus::Pending => "⏳",
90+
StepStatus::Completed => "✅",
91+
};
92+
info!(" {}. {} {}", i + 1, status_icon, step.description);
93+
}
94+
Ok(AgentStateMutation::default())
95+
}
96+
97+
async fn on_state_delta_event(
98+
&self,
99+
event: &StateDeltaEvent,
100+
_params: AgentSubscriberParams<'async_trait, Plan, FwdPropsT>
101+
) -> Result<AgentStateMutation<Plan>, AgentError> {
102+
info!("🔄 State delta received:");
103+
for patch in &event.delta {
104+
match patch.get("op").and_then(|v| v.as_str()) {
105+
Some("replace") => {
106+
if let (Some(path), Some(value)) = (
107+
patch.get("path").and_then(|v| v.as_str()),
108+
patch.get("value")
109+
) {
110+
if path.contains("/status") {
111+
let status = value.as_str().unwrap_or("unknown");
112+
let status_icon = match status {
113+
"completed" => "✅",
114+
"pending" => "⏳",
115+
_ => "❓",
116+
};
117+
info!(" {} Step status updated to: {}", status_icon, status);
118+
} else if path.contains("/description") {
119+
info!(" 📝 Step description updated to: {}", value.as_str().unwrap_or("unknown"));
120+
}
121+
}
122+
}
123+
Some(op) => info!(" Operation: {}", op),
124+
None => info!(" Unknown operation"),
125+
}
126+
}
127+
Ok(AgentStateMutation::default())
128+
}
129+
130+
async fn on_state_changed(
131+
&self,
132+
params: AgentSubscriberParams<'async_trait, Plan, FwdPropsT>
133+
) -> Result<(), AgentError> {
134+
info!("🔄 Overall state changed");
135+
let completed_steps = params.state.steps.iter()
136+
.filter(|step| matches!(step.status, StepStatus::Completed))
137+
.count();
138+
info!(" Progress: {}/{} steps completed", completed_steps, params.state.steps.len());
139+
140+
Ok(())
141+
}
142+
}
143+
144+
#[tokio::main]
145+
async fn main() -> Result<(), Box<dyn Error>> {
146+
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
147+
148+
let base_url = Url::parse("http://127.0.0.1:3001/")?;
149+
let headers = HeaderMap::new();
150+
151+
// Create the HTTP agent
152+
let agent = HttpAgent::new(base_url, headers);
153+
154+
let subscriber = GenerativeUiSubscriber::new();
155+
156+
// Create run parameters for testing generative UI with planning
157+
let params = RunAgentParams {
158+
messages: vec![
159+
Message::User {
160+
id: MessageId::random(),
161+
content: "I need to organize a birthday party for my friend. Can you help me \
162+
create a plan? When you have created the plan, please fully execute it.".into(),
163+
name: None,
164+
}
165+
],
166+
forwarded_props: Some(JsonValue::Null),
167+
..Default::default()
168+
};
169+
170+
info!("Starting generative UI agent run...");
171+
info!("Testing planning functionality with state snapshots and deltas");
172+
173+
let result = agent.run_agent(&params, vec![Arc::new(subscriber)]).await?;
174+
175+
info!("Agent run completed successfully!");
176+
info!("Final result: {}", result.result);
177+
info!("Generated {} new messages", result.new_messages.len());
178+
info!("Final state: {:#?}", result.new_state);
179+
180+
// Print the messages for debugging
181+
for (i, message) in result.new_messages.iter().enumerate() {
182+
info!("Message {}: {:?}", i + 1, message);
183+
}
184+
185+
Ok(())
186+
}

rust-sdk/crates/ag-ui-client/examples/shared_state.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
142142
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
143143

144144
let base_url = Url::parse("http://127.0.0.1:3001/")?;
145-
146-
// Create headers
147-
let mut headers = HeaderMap::new();
148-
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
145+
let headers = HeaderMap::new();
149146

150147
// Create the HTTP agent
151148
let agent = HttpAgent::new(base_url, headers);
@@ -170,7 +167,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
170167

171168
let result = agent.run_agent(&params, vec![Arc::new(subscriber)]).await?;
172169

173-
info!("Agent run finished. Final state: {:#?}", result.result);
170+
info!("Agent run finished. Final result: {:#?}", result);
174171

175172
Ok(())
176173
}

rust-sdk/crates/ag-ui-client/scripts/pydantic_ai_server.py renamed to rust-sdk/crates/ag-ui-client/scripts/basic_agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pydantic_ai.providers.openai import OpenAIProvider
1313

1414
model = OpenAIModel(
15-
model_name="llama3.1",
15+
model_name="gpt-oss:20b",
1616
provider=OpenAIProvider(
1717
base_url="http://localhost:11434/v1", api_key="ollama"
1818
),
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# /// script
2+
# requires-python = ">=3.12"
3+
# dependencies = [
4+
# "uvicorn == 0.34.3",
5+
# "pydantic-ai==0.4.10"
6+
# ]
7+
# ///
8+
9+
from __future__ import annotations
10+
11+
from textwrap import dedent
12+
from typing import Any, Literal
13+
14+
from pydantic import BaseModel, Field
15+
16+
from ag_ui.core import EventType, StateDeltaEvent, StateSnapshotEvent
17+
import uvicorn
18+
from pydantic_ai import Agent
19+
from pydantic_ai.models.openai import OpenAIModel
20+
from pydantic_ai.providers.openai import OpenAIProvider
21+
from pydantic_ai.ag_ui import StateDeps
22+
23+
StepStatus = Literal['pending', 'completed']
24+
25+
26+
class Step(BaseModel):
27+
"""Represents a step in a plan."""
28+
29+
description: str = Field(description='The description of the step')
30+
status: StepStatus = Field(
31+
default='pending',
32+
description='The status of the step (e.g., pending, completed)',
33+
)
34+
35+
36+
class Plan(BaseModel):
37+
"""Represents a plan with multiple steps."""
38+
39+
steps: list[Step] = Field(default_factory=list, description='The steps in the plan')
40+
41+
42+
class JSONPatchOp(BaseModel):
43+
"""A class representing a JSON Patch operation (RFC 6902)."""
44+
45+
op: Literal['add', 'remove', 'replace', 'move', 'copy', 'test'] = Field(
46+
description='The operation to perform: add, remove, replace, move, copy, or test',
47+
)
48+
path: str = Field(description='JSON Pointer (RFC 6901) to the target location')
49+
value: Any = Field(
50+
default=None,
51+
description='The value to apply (for add, replace operations)',
52+
)
53+
from_: str | None = Field(
54+
default=None,
55+
alias='from',
56+
description='Source path (for move, copy operations)',
57+
)
58+
59+
model = OpenAIModel(
60+
model_name="gpt-oss:20b",
61+
provider=OpenAIProvider(
62+
base_url="http://localhost:11434/v1", api_key="ollama",
63+
),
64+
65+
)
66+
agent = Agent(
67+
model=model,
68+
instructions=dedent(
69+
"""
70+
When planning use tools only, without any other messages.
71+
IMPORTANT:
72+
- Use the `create_plan` tool to set the initial state of the steps
73+
- Use the `update_plan_step` tool to update the status of each step
74+
- Do NOT repeat the plan or summarise it in a message
75+
- Do NOT confirm the creation or updates in a message
76+
- Do NOT ask the user for additional information or next steps
77+
78+
Only one plan can be active at a time, so do not call the `create_plan` tool
79+
again until all the steps in current plan are completed.
80+
"""
81+
),
82+
retries=3
83+
)
84+
85+
86+
@agent.tool_plain
87+
async def create_plan(steps: list[str]) -> StateSnapshotEvent:
88+
"""Create a plan with multiple steps.
89+
90+
Args:
91+
steps: List of step descriptions to create the plan.
92+
93+
Returns:
94+
StateSnapshotEvent containing the initial state of the steps.
95+
"""
96+
plan: Plan = Plan(
97+
steps=[Step(description=step) for step in steps],
98+
)
99+
return StateSnapshotEvent(
100+
type=EventType.STATE_SNAPSHOT,
101+
snapshot=plan.model_dump(),
102+
)
103+
104+
105+
@agent.tool_plain
106+
async def update_plan_step(
107+
index: int, description: str | None = None, status: StepStatus | None = None
108+
) -> StateDeltaEvent:
109+
"""Update the plan with new steps or changes.
110+
111+
Args:
112+
index: The index of the step to update.
113+
description: The new description for the step.
114+
status: The new status for the step.
115+
116+
Returns:
117+
StateDeltaEvent containing the changes made to the plan.
118+
"""
119+
changes: list[JSONPatchOp] = []
120+
if description is not None:
121+
changes.append(
122+
JSONPatchOp(
123+
op='replace', path=f'/steps/{index}/description', value=description
124+
)
125+
)
126+
if status is not None:
127+
changes.append(
128+
JSONPatchOp(op='replace', path=f'/steps/{index}/status', value=status)
129+
)
130+
return StateDeltaEvent(
131+
type=EventType.STATE_DELTA,
132+
delta=changes,
133+
)
134+
135+
136+
app = agent.to_ag_ui(deps=StateDeps(Plan()))
137+
138+
139+
if __name__ == "__main__":
140+
uvicorn.run(app, port=3001)

rust-sdk/crates/ag-ui-client/scripts/shared_state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ class RecipeSnapshot(BaseModel):
9393
)
9494

9595
model = OpenAIModel(
96-
model_name="llama3.1",
96+
model_name="gpt-oss:20b",
9797
provider=OpenAIProvider(
9898
base_url="http://localhost:11434/v1", api_key="ollama"
9999
),

0 commit comments

Comments
 (0)