Skip to content

Commit 3c72b0d

Browse files
committed
Add plan approval and mplan management features
Introduces plan approval workflow, including PlanStatus.approved, mplan CRUD operations, and plan deletion by ID. Refactors CosmosDB and DatabaseBase to support new mplan methods, updates PlanService for approval/rejection logic, and improves frontend plan parsing and state management. Minor prompt and logging adjustments included.
1 parent d53e031 commit 3c72b0d

File tree

10 files changed

+122
-81
lines changed

10 files changed

+122
-81
lines changed

data/agent_teams/retail.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
{
6565
"id": "task-1",
6666
"name": "Satisfaction Plan",
67-
"prompt": "Create a plan to improve Emily Thompson's satisfaction.",
67+
"prompt": "Create a plan to improve Emily Thompson's satisfaction.",
6868
"created": "",
6969
"creator": "",
7070
"logo": ""

src/backend/common/database/cosmosdb.py

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from azure.cosmos.aio import CosmosClient
1212
from azure.cosmos.aio._database import DatabaseProxy
1313
from azure.cosmos.exceptions import CosmosResourceExistsError
14-
from pytest import Session
14+
import v3.models.messages as messages
1515

1616
from common.models.messages_kernel import (
1717
AgentMessage,
@@ -24,7 +24,6 @@
2424
from .database_base import DatabaseBase
2525
from ..models.messages_kernel import (
2626
BaseDataModel,
27-
Session,
2827
Plan,
2928
Step,
3029
AgentMessage,
@@ -38,7 +37,6 @@ class CosmosDBClient(DatabaseBase):
3837
"""CosmosDB implementation of the database interface."""
3938

4039
MODEL_CLASS_MAPPING = {
41-
DataType.session: Session,
4240
DataType.plan: Plan,
4341
DataType.step: Step,
4442
DataType.agent_message: AgentMessage,
@@ -200,17 +198,6 @@ async def update_plan(self, plan: Plan) -> None:
200198
"""Update a plan in CosmosDB."""
201199
await self.update_item(plan)
202200

203-
async def get_plan_by_session(self, session_id: str) -> Optional[Plan]:
204-
"""Retrieve a plan by session_id."""
205-
query = (
206-
"SELECT * FROM c WHERE c.session_id=@session_id AND c.data_type=@data_type"
207-
)
208-
parameters = [
209-
{"name": "@session_id", "value": session_id},
210-
{"name": "@data_type", "value": DataType.plan},
211-
]
212-
results = await self.query_items(query, parameters, Plan)
213-
return results[0] if results else None
214201

215202
async def get_plan_by_plan_id(self, plan_id: str) -> Optional[Plan]:
216203
"""Retrieve a plan by plan_id."""
@@ -360,27 +347,6 @@ async def delete_team(self, team_id: str) -> bool:
360347
logging.exception(f"Failed to delete team from Cosmos DB: {e}")
361348
return False
362349

363-
async def get_data_by_type_and_session_id(
364-
self, data_type: str, session_id: str
365-
) -> List[BaseDataModel]:
366-
"""Query the Cosmos DB for documents with the matching data_type, session_id and user_id."""
367-
await self._ensure_initialized()
368-
if self.container is None:
369-
return []
370-
371-
model_class = self.MODEL_CLASS_MAPPING.get(data_type, BaseDataModel)
372-
try:
373-
query = "SELECT * FROM c WHERE c.session_id=@session_id AND c.user_id=@user_id AND c.data_type=@data_type ORDER BY c._ts ASC"
374-
parameters = [
375-
{"name": "@session_id", "value": session_id},
376-
{"name": "@data_type", "value": data_type},
377-
{"name": "@user_id", "value": self.user_id},
378-
]
379-
return await self.query_items(query, parameters, model_class)
380-
except Exception as e:
381-
logging.exception(f"Failed to query data by type from Cosmos DB: {e}")
382-
return []
383-
384350
# Data Management Operations
385351
async def get_data_by_type(self, data_type: str) -> List[BaseDataModel]:
386352
"""Retrieve all data of a specific type."""
@@ -477,3 +443,40 @@ async def update_current_team(self, current_team: UserCurrentTeam) -> None:
477443
"""Update the current team for a user."""
478444
await self._ensure_initialized()
479445
await self.update_item(current_team)
446+
447+
async def delete_plan_by_plan_id(self, plan_id: str) -> bool:
448+
"""Delete a plan by its ID."""
449+
query = "SELECT c.id, c.session_id FROM c WHERE c.id=@plan_id "
450+
451+
params = [
452+
{"name": "@plan_id", "value": plan_id},
453+
]
454+
items = self.container.query_items(query=query, parameters=params)
455+
print("Items to delete planid:", items)
456+
if items:
457+
async for doc in items:
458+
try:
459+
await self.container.delete_item(doc["id"], partition_key=doc["session_id"])
460+
except Exception as e:
461+
self.logger.warning("Failed deleting current team doc %s: %s", doc.get("id"), e)
462+
463+
return True
464+
465+
async def add_mplan(self, mplan: messages.MPlan) -> None:
466+
"""Add a team configuration to the database."""
467+
await self.add_item(mplan)
468+
469+
async def update_mplan(self, mplan: messages.MPlan) -> None:
470+
"""Update a team configuration in the database."""
471+
await self.update_item(mplan)
472+
473+
474+
async def get_mplan(self, plan_id: str) -> Optional[messages.MPlan]:
475+
"""Retrieve a mplan configuration by mplan_id."""
476+
query = "SELECT * FROM c WHERE c.plan_id=@plan_id AND c.data_type=@data_type"
477+
parameters = [
478+
{"name": "@plan_id", "value": plan_id},
479+
{"name": "@data_type", "value": DataType.m_plan},
480+
]
481+
results = await self.query_items(query, parameters, messages.MPlan)
482+
return results[0] if results else None

src/backend/common/database/database_base.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from abc import ABC, abstractmethod
44
from typing import Any, Dict, List, Optional, Type
5-
5+
import v3.models.messages as messages
66
from ..models.messages_kernel import (
77
BaseDataModel,
88
Plan,
@@ -97,11 +97,7 @@ async def get_all_plans_by_team_id_status(
9797
"""Retrieve all plans for a specific team."""
9898
pass
9999

100-
@abstractmethod
101-
async def get_data_by_type_and_session_id(
102-
self, data_type: str, session_id: str
103-
) -> List[BaseDataModel]:
104-
pass
100+
105101

106102
# Step Operations
107103
@abstractmethod
@@ -199,3 +195,23 @@ async def set_current_team(self, current_team: UserCurrentTeam) -> None:
199195
async def update_current_team(self, current_team: UserCurrentTeam) -> None:
200196
"""Update the current team for a user."""
201197
pass
198+
199+
@abstractmethod
200+
async def delete_plan_by_plan_id(self, plan_id: str) -> bool:
201+
"""Retrieve the current team for a user."""
202+
pass
203+
204+
@abstractmethod
205+
async def add_mplan(self, mplan: messages.MPlan) -> None:
206+
"""Add a team configuration to the database."""
207+
pass
208+
209+
@abstractmethod
210+
async def update_mplan(self, mplan: messages.MPlan) -> None:
211+
"""Update a team configuration in the database."""
212+
pass
213+
214+
@abstractmethod
215+
async def get_mplan(self, plan_id: str) -> Optional[messages.MPlan]:
216+
"""Retrieve a mplan configuration by mplan_id."""
217+
pass

src/backend/common/models/messages_kernel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class PlanStatus(str, Enum):
5555
completed = "completed"
5656
failed = "failed"
5757
canceled = "canceled"
58+
approved = "approved"
5859

5960

6061
class HumanFeedbackStatus(str, Enum):

src/backend/v3/api/router.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -923,9 +923,9 @@ async def select_team(selection: TeamSelectionRequest, request: Request):
923923
)
924924

925925
# save to in-memory config for current user
926-
team_config.set_current_team(
927-
user_id=user_id, team_configuration=team_configuration
928-
)
926+
# team_config.set_current_team(
927+
# user_id=user_id, team_configuration=team_configuration
928+
# )
929929

930930
# Track the team selection event
931931
track_event_if_configured(
@@ -1142,17 +1142,13 @@ async def get_plan_by_id(request: Request, plan_id: str):
11421142

11431143
# Use get_steps_by_plan to match the original implementation
11441144
steps = await memory_store.get_steps_by_plan(plan_id=plan.id)
1145-
messages = await memory_store.get_data_by_type_and_session_id(
1146-
"agent_message", session_id=plan.session_id
1147-
)
1145+
messages = []
11481146

11491147
plan_with_steps = PlanWithSteps(**plan.model_dump(), steps=steps)
11501148
plan_with_steps.update_step_counts()
11511149

11521150
# Format dates in messages according to locale
1153-
formatted_messages = format_dates_in_messages(
1154-
messages, config.get_user_local_browser_language()
1155-
)
1151+
formatted_messages = []
11561152

11571153
return [plan_with_steps, formatted_messages]
11581154
else:

src/backend/v3/common/services/plan_service.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from common.database.database_factory import DatabaseFactory
44
from common.database.database_base import DatabaseBase
55
import v3.models.messages as messages
6+
from common.models.messages_kernel import PlanStatus
67
from v3.config.settings import orchestration_config
78
from common.utils.event_utils import track_event_if_configured
89

@@ -30,22 +31,44 @@ async def handle_plan_approval(human_feedback: messages.PlanApprovalResponse, us
3031
return False
3132
try:
3233
mplan = orchestration_config.plans[human_feedback.m_plan_id]
34+
memory_store = await DatabaseFactory.get_database(user_id=user_id)
3335
if hasattr(mplan, "plan_id"):
3436
print(
3537
"Updated orchestration config:",
3638
orchestration_config.plans[human_feedback.m_plan_id],
3739
)
38-
mplan.plan_id = human_feedback.plan_id
39-
orchestration_config.plans[human_feedback.m_plan_id] = mplan
40-
memory_store = await DatabaseFactory.get_database(user_id=user_id)
41-
plan = await memory_store.get_plan(human_feedback.plan_id)
42-
if plan:
43-
print("Retrieved plan from memory store:", plan)
44-
40+
if human_feedback.approved:
41+
mplan.plan_id = human_feedback.plan_id
42+
43+
orchestration_config.plans[human_feedback.m_plan_id] = mplan
4544

46-
else:
47-
print("Plan not found in memory store.")
48-
return False
45+
plan = await memory_store.get_plan(human_feedback.plan_id)
46+
mplan.team_id = plan.team_id # just to keep consistency
47+
if plan:
48+
plan.overall_status = PlanStatus.approved
49+
await memory_store.update_plan(plan)
50+
await memory_store.add_mplan(mplan)
51+
track_event_if_configured(
52+
"PlanApproved",
53+
{
54+
"m_plan_id": human_feedback.m_plan_id,
55+
"plan_id": human_feedback.plan_id,
56+
"user_id": user_id,
57+
},
58+
)
59+
else:
60+
print("Plan not found in memory store.")
61+
return False
62+
else: #reject plan
63+
track_event_if_configured(
64+
"PlanRejected",
65+
{
66+
"m_plan_id": human_feedback.m_plan_id,
67+
"plan_id": human_feedback.plan_id,
68+
"user_id": user_id,
69+
},
70+
)
71+
await memory_store.delete_plan_by_plan_id(human_feedback.plan_id)
4972

5073
except Exception as e:
5174
print(f"Error processing plan approval: {e}")
@@ -70,12 +93,12 @@ async def handle_agent_messages(standard_message: messages.AgentMessage) -> bool
7093
return True
7194

7295
@staticmethod
73-
async def handle_human_clarification(standard_message: messages.AgentMessage) -> bool:
96+
async def handle_human_clarification(human_feedback: messages.UserClarificationResponse) -> bool:
7497
"""
75-
Process an AgentMessage coming from the client.
98+
Process a UserClarificationResponse coming from the client.
7699
77100
Args:
78-
standard_message: messages.AgentMessage (contains relevant message data)
101+
human_feedback: messages.UserClarificationResponse (contains relevant message data)
79102
user_id: authenticated user id
80103
81104
Returns:

src/backend/v3/config/settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
OpenAIChatPromptExecutionSettings,
1919
)
2020

21-
from v3.models.messages import WebsocketMessageType
21+
from v3.models.messages import WebsocketMessageType, MPlan
2222

2323
logger = logging.getLogger(__name__)
2424

@@ -85,7 +85,7 @@ def __init__(self):
8585
self.orchestrations: Dict[str, MagenticOrchestration] = (
8686
{}
8787
) # user_id -> orchestration instance
88-
self.plans: Dict[str, any] = {} # plan_id -> plan details
88+
self.plans: Dict[str, MPlan] = {} # plan_id -> plan details
8989
self.approvals: Dict[str, bool] = {} # m_plan_id -> approval status
9090
self.sockets: Dict[str, WebSocket] = {} # user_id -> WebSocket
9191
self.clarifications: Dict[str, str] = {} # m_plan_id -> clarification response

src/frontend/src/components/content/PlanPanelLeft.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ const PlanPanelLeft: React.FC<PlanPanelLefProps> = ({
8585

8686
useEffect(() => {
8787
loadPlansData();
88-
}, [loadPlansData]);
88+
setUserInfo(getUserInfoGlobal());
89+
}, [loadPlansData, setUserInfo]);
8990

9091
useEffect(() => {
9192
if (plans) {

src/frontend/src/services/PlanDataService.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ export class PlanDataService {
365365
* - "AgentMessage(agent_name='X', timestamp=..., content='...')"
366366
* Returns a structured object with steps parsed from markdown-ish content.
367367
*/
368+
// ...inside class PlanDataService
368369
static parseAgentMessage(rawData: any): {
369370
agent: string;
370371
agent_type: AgentMessageType;
@@ -381,7 +382,9 @@ export class PlanDataService {
381382
} | null {
382383
try {
383384
// Unwrap wrapper
384-
if (rawData && typeof rawData === 'object' && rawData.type === WebsocketMessageType.AGENT_MESSAGE && typeof rawData.data === 'string') {
385+
if (rawData && typeof rawData === 'object' &&
386+
rawData.type === WebsocketMessageType.AGENT_MESSAGE &&
387+
typeof rawData.data === 'string') {
385388
return this.parseAgentMessage(rawData.data);
386389
}
387390

@@ -395,22 +398,22 @@ export class PlanDataService {
395398
source.match(/agent_name="([^"]+)"/)?.[1] ||
396399
'UnknownAgent';
397400

398-
const timestampStr =
399-
source.match(/timestamp=([\d.]+)/)?.[1];
401+
const timestampStr = source.match(/timestamp=([\d.]+)/)?.[1];
400402
const timestamp = timestampStr ? Number(timestampStr) : null;
401403

402-
// Extract content='...'
403-
const contentMatch = source.match(/content='((?:\\'|[^'])*)'/);
404-
let content = contentMatch ? contentMatch[1] : '';
405-
// Unescape
404+
// Support single or double quoted content
405+
const contentMatch = source.match(/content=(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)')/);
406+
let content = contentMatch ? (contentMatch[1] ?? contentMatch[2] ?? '') : '';
406407
content = content
407408
.replace(/\\n/g, '\n')
408409
.replace(/\\'/g, "'")
409410
.replace(/\\"/g, '"')
410411
.replace(/\\\\/g, '\\');
411412

412-
// Parse sections of the form "##### Title Completed"
413-
// Each block ends at --- line or next "##### " or end.
413+
// Simplify human clarification inline if present
414+
content = this.simplifyHumanClarification(content);
415+
416+
// Parse sections
414417
const lines = content.split('\n');
415418
const steps: Array<{ title: string; fields: Record<string, string>; summary?: string; raw_block: string; }> = [];
416419
let i = 0;
@@ -424,7 +427,6 @@ export class PlanDataService {
424427
blockLines.push(lines[i]);
425428
i++;
426429
}
427-
// Skip separator line if present
428430
if (i < lines.length && /^---\s*$/.test(lines[i])) i++;
429431

430432
const fields: Record<string, string> = {};
@@ -437,9 +439,7 @@ export class PlanDataService {
437439
if (fieldName) fields[fieldName] = value;
438440
} else {
439441
const summaryMatch = bl.match(/^AGENT SUMMARY:\s*(.+)$/i);
440-
if (summaryMatch) {
441-
summary = summaryMatch[1].trim();
442-
}
442+
if (summaryMatch) summary = summaryMatch[1].trim();
443443
}
444444
}
445445

@@ -454,7 +454,7 @@ export class PlanDataService {
454454
}
455455
}
456456

457-
// Next Steps section
457+
// Next Steps
458458
const nextSteps: string[] = [];
459459
const nextIdx = lines.findIndex(l => /^Next Steps:/.test(l.trim()));
460460
if (nextIdx !== -1) {

0 commit comments

Comments
 (0)